From c130705cc77e0435913677fab826583355a8ceb2 Mon Sep 17 00:00:00 2001 From: Edward Wibowo Date: Sat, 6 Feb 2021 23:57:56 +0800 Subject: [PATCH] feat: implement syntax highlighting --- Makefile | 3 +- go.mod | 1 + go.sum | 33 +++++++++++++++++ highlighter.go | 89 +++++++++++++++++++++++++++++++++++++++++++++ highlighter_test.go | 43 ++++++++++++++++++++++ parser.go | 2 +- render.go | 23 ++++++------ search.go | 2 +- target.go | 11 +++--- 9 files changed, 187 insertions(+), 20 deletions(-) create mode 100644 highlighter.go create mode 100644 highlighter_test.go diff --git a/Makefile b/Makefile index 73288a8..875b3a4 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ PROJECT = make-tui BUILD_DIR ?= build -APP_SOURCES = parser.go \ +APP_SOURCES = highlighter.go \ + parser.go \ render.go \ search.go \ target.go \ diff --git a/go.mod b/go.mod index a2a0e7b..b337391 100755 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/claby2/make-tui go 1.14 require ( + github.com/alecthomas/chroma v0.8.2 github.com/gizak/termui/v3 v3.1.0 github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/nsf/termbox-go v0.0.0-20201124104050-ed494de23a00 // indirect diff --git a/go.sum b/go.sum index b98dc8c..fb4e612 100755 --- a/go.sum +++ b/go.sum @@ -1,5 +1,24 @@ +github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U= +github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI= +github.com/alecthomas/chroma v0.8.2 h1:x3zkuE2lUk/RIekyAJ3XRqSCP4zwWDfcw/YJCuCAACg= +github.com/alecthomas/chroma v0.8.2/go.mod h1:sko8vR34/90zvl5QdcUdvzL3J8NKjAUx9va9jPuFNoM= +github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo= +github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0= +github.com/alecthomas/kong v0.2.4/go.mod h1:kQOmtJgV+Lb4aj+I2LEn40cbtawdWJ9Y8QLq+lElKxE= +github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 h1:p9Sln00KOTlrYkxI1zYWl1QLnEqAqEARBEYa8FQnQcY= +github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= +github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ= +github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk= +github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/gizak/termui/v3 v3.1.0 h1:ZZmVDgwHl7gR7elfKf1xc4IudXZ5qqfDh4wExk4Iajc= github.com/gizak/termui/v3 v3.1.0/go.mod h1:bXQEBkJpzxUAKf0+xq9MSWAvWZlE7c+aidmyFlkYTrY= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-runewidth v0.0.2 h1:UnlwIPBGaTZfPQ6T1IGzPI0EkYAQmT9fAEJ/poFC63o= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= @@ -12,3 +31,17 @@ github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d h1:x3S6kxmy49zXVVyh github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= github.com/nsf/termbox-go v0.0.0-20201124104050-ed494de23a00 h1:Rl8NelBe+n7SuLbJyw13ho7CGWUt2BjGGKIoreCWQ/c= github.com/nsf/termbox-go v0.0.0-20201124104050-ed494de23a00/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200413165638-669c56c373c4 h1:opSr2sbRXk5X5/givKrrKj9HXxFpW2sdCiP8MJSKLQY= +golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/highlighter.go b/highlighter.go new file mode 100644 index 0000000..f6401b9 --- /dev/null +++ b/highlighter.go @@ -0,0 +1,89 @@ +package main + +import ( + "log" + "math" + + "github.com/alecthomas/chroma" + "github.com/alecthomas/chroma/lexers" + "github.com/alecthomas/chroma/styles" +) + +var c = chroma.MustParseColour + +// ColorMap associates termui color names with a respective chroma color +var ColorMap = map[string]chroma.Colour{ + "red": c("#ff0000"), + "blue": c("#0000ff"), + "black": c("#000000"), + "cyan": c("#00ffff"), + "yellow": c("#ffff00"), + "white": c("#ffffff"), + "green": c("#00ff00"), + "magenta": c("#ff00ff"), +} + +// Highlighter helps facilitate makefile syntax highlighting +type Highlighter struct { + style *chroma.Style +} + +// NewHighlighter constructs a Highlighter and sets the style based on the given styleName +func NewHighlighter(styleName string) *Highlighter { + style := styles.Get(styleName) + if style == nil { + style = styles.Fallback + } + return &Highlighter{style} +} + +// GetHighlightedContent iterates through the given content slice and returns it with inline style annotations +func (highlighter *Highlighter) GetHighlightedContent(content []string) []string { + lexer := lexers.Get("Base Makefile") + + var highlightedContent []string + currentLine := "" + for _, line := range content { + iterator, err := lexer.Tokenise(nil, line) + if err != nil { + log.Fatal(err) + } + for token := iterator(); token != chroma.EOF; token = iterator() { + if token.Value == "\n" { + highlightedContent = append(highlightedContent, currentLine) + currentLine = "" + continue + } + entry := highlighter.style.Get(token.Type) + fg := "clear" + if entry.Colour.IsSet() { + fg = approximateColor(entry.Colour) + } + style := "fg:" + fg + if entry.Bold == chroma.Yes { + style += ",mod:bold" + } else if entry.Underline == chroma.Yes { + style += ",mod:underline" + } + currentLine += "[" + token.Value + "](" + style + ")" + } + if currentLine != "" { + highlightedContent = append(highlightedContent, currentLine) + currentLine = "" + } + } + return highlightedContent +} + +func approximateColor(color chroma.Colour) string { + lowestDistance := math.MaxFloat64 + var bestColor string + for colorName, c := range ColorMap { + distance := color.Distance(c) + if distance < lowestDistance { + lowestDistance = distance + bestColor = colorName + } + } + return bestColor +} diff --git a/highlighter_test.go b/highlighter_test.go new file mode 100644 index 0000000..1a77597 --- /dev/null +++ b/highlighter_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "testing" +) + +func TestGetHighlightedContent(t *testing.T) { + content := []string{ + "PROJECT = make-tui", + "BUILD_DIR ?= build", + "APP_SOURCES = parser.go \\", + " render.go \\", + " search.go \\", + " target.go \\", + " main.go \\", + "", + "build: $(APP_SOURCES)", + " go build -o $(BUILD_DIR)/make-tui $(APP_SOURCES)", + ".PHONY: build", + "", + "run: $(APP_SOURCES)", + " go run $(APP_SOURCES)", + ".PHONY: run", + "", + "test:", + " go test ./...", + ".PHONY: test", + "", + "clean:", + " rm -rf $(BUILD_DIR)", + ".PHONY: clean", + } + expected := len(content) + highlighter := NewHighlighter("emacs") + highlightedContent := highlighter.GetHighlightedContent(content) + + result := len(highlightedContent) + + if result != expected { + t.Errorf("expected highlighted content %d, received %d", expected, result) + } + +} diff --git a/parser.go b/parser.go index 7f99d18..7ff98f5 100644 --- a/parser.go +++ b/parser.go @@ -45,7 +45,7 @@ func (parsedContent *ParsedContent) Parse() { // Handle multiline comments if inMultilineComment { inTarget = false - inMultilineComment = line[len(line)-1:] == "\\" + inMultilineComment = len(line) > 0 && line[len(line)-1:] == "\\" // If currently in multiline comment, the entire line is commented continue } else { diff --git a/render.go b/render.go index 401ce29..4d70f4c 100644 --- a/render.go +++ b/render.go @@ -23,13 +23,15 @@ func Render(content *ParsedContent) { target := NewTarget(0, len(content.rules), content.rules) target.Rows = getTargets(content.rules) + highlighter := NewHighlighter("vim") + dependencyWidget := widgets.NewParagraph() dependencyWidget.Title = "Dependencies" dependencyWidget.Text = getDependency(content.rules, target.Index) contentWidget := widgets.NewParagraph() contentWidget.Title = content.filePath - contentWidget.Text = getHighlightedContent(content.content, content.rules, termHeight, target.Index) + contentWidget.Text = getContent(content.content, highlighter, content.rules, termHeight, target.Index) grid := ui.NewGrid() grid.SetRect(0, 0, termWidth, termHeight) @@ -113,13 +115,14 @@ func Render(content *ParsedContent) { } target.Index = target.SelectedRow dependencyWidget.Text = getDependency(content.rules, target.Index) - contentWidget.Text = getHighlightedContent(content.content, content.rules, termHeight, target.Index) + contentWidget.Text = getContent(content.content, highlighter, content.rules, termHeight, target.Index) ui.Render(grid) } ui.Close() - if run && target.Name != "" { - cmd := exec.Command("make", "-f"+content.filePath, target.Name) + targetName := target.GetName() + if run && targetName != "" { + cmd := exec.Command("make", "-f"+content.filePath, targetName) stdout, _ := cmd.StdoutPipe() Check(cmd.Start) @@ -153,23 +156,21 @@ func getDependency(rules []Rule, index int) string { return "" } -func getHighlightedContent(content []string, rules []Rule, termHeight, index int) string { +func getContent(content []string, highlighter *Highlighter, rules []Rule, termHeight, index int) string { contentCopy := append([]string(nil), content...) + highlightedContent := highlighter.GetHighlightedContent(contentCopy) firstLine := 0 if index < len(rules) { lineNumber := rules[index].lineNumber - numberOfCommands := len(rules[index].commands) - if len(contentCopy) > termHeight-1 { + if len(content) > termHeight-1 { firstLine = lineNumber } // Highlight rule (including commands) - for i := lineNumber; i <= lineNumber+numberOfCommands; i++ { - contentCopy[i] = "[" + contentCopy[i] + "](fg:black,bg:white,mod:bold)" - } + highlightedContent[lineNumber] = "[" + content[lineNumber] + "](fg:black,bg:white,mod:bold)" } - return strings.ReplaceAll(strings.Join(contentCopy[firstLine:], "\n"), "\t", strings.Repeat(" ", 4)) + return strings.ReplaceAll(strings.Join(highlightedContent[firstLine:], "\n"), "\t", strings.Repeat(" ", 4)) } func replaceTabs(content []string) []string { diff --git a/search.go b/search.go index c10212e..f2577f0 100644 --- a/search.go +++ b/search.go @@ -15,7 +15,7 @@ type Search struct { content string } -// NewSearch constructs a Search and sets active to false and content to an empty strin` +// NewSearch constructs a Search and sets active to false and content to an empty string func NewSearch() *Search { return &Search{active: false, content: ""} } diff --git a/target.go b/target.go index 37ec34b..911dad8 100644 --- a/target.go +++ b/target.go @@ -11,7 +11,6 @@ type Target struct { Search *Search Index int - Name string targets []string numberOfRules int } @@ -22,15 +21,10 @@ func NewTarget(index, numberOfRules int, rules []Rule) *Target { for _, rule := range rules { targets = append(targets, rule.target) } - var name string - if len(targets) > 0 { - name = targets[0] - } target := &Target{ List: widgets.NewList(), Index: index, numberOfRules: numberOfRules, - Name: name, targets: targets, } @@ -56,6 +50,11 @@ func (target *Target) FindTarget(goalTargetName string) int { return -1 } +// GetName returns the name of the currently selected target +func (target *Target) GetName() string { + return target.targets[target.Index] +} + // SetRect sets the rectangle for the target widget for rendering func (target *Target) SetRect(x1, y1, x2, y2 int) { target.List.SetRect(x1, y1, x2, y2)