Skip to content

Commit d1a1db9

Browse files
committed
code annotations and bookmarks
1 parent d7181f3 commit d1a1db9

File tree

6 files changed

+285
-0
lines changed

6 files changed

+285
-0
lines changed

cmd/bob/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
"github.com/function61/gokit/app/dynversion"
88
"github.com/function61/gokit/os/osutil"
9+
"github.com/function61/turbobob/pkg/bookmarkparser"
910
"github.com/function61/turbobob/pkg/powerline"
1011
"github.com/spf13/cobra"
1112
)
@@ -83,6 +84,7 @@ func toolsEntry() *cobra.Command {
8384
// TODO: move powerline here?
8485
cmd.AddCommand(initEntry())
8586
cmd.AddCommand(langserverEntry())
87+
cmd.AddCommand(bookmarkparser.Entrypoint())
8688

8789
return cmd
8890
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package annotationparser
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io/fs"
7+
"path/filepath"
8+
)
9+
10+
type Parser interface {
11+
Parse(ctx context.Context, path string, processAnnotation func(Location) error) error
12+
}
13+
14+
type Annotations map[string]interface{}
15+
16+
// the location of the annotation in a file
17+
type Location struct {
18+
File string
19+
Line int // the line the annotation is found from. (the line the annotation describes is usually next line)
20+
Offset int
21+
Annotations Annotations
22+
}
23+
24+
func (a Location) LineTarget() int {
25+
return a.Line + 1
26+
}
27+
28+
func ScanDirectoryFilesRecursively(ctx context.Context, dir string, processAnnotation func(Location) error, parsers map[string]Parser) error {
29+
if err := filepath.Walk(dir, func(path string, info fs.FileInfo, err error) error {
30+
if err != nil {
31+
return err
32+
}
33+
34+
if isIgnoredEntryName(info.Name()) {
35+
return fs.SkipDir
36+
}
37+
38+
if info.IsDir() { // only process files
39+
return nil
40+
}
41+
42+
ext := filepath.Ext(path)
43+
if parser, found := parsers[ext]; found {
44+
fullPath := filepath.Join(dir, path)
45+
46+
if err := parser.Parse(ctx, fullPath, processAnnotation); err != nil {
47+
return fmt.Errorf("Parse %s: %w", fullPath, err)
48+
}
49+
}
50+
51+
return nil
52+
}); err != nil {
53+
return err
54+
}
55+
56+
return nil
57+
}
58+
59+
func isIgnoredEntryName(name string) bool {
60+
return name == ".git"
61+
}

pkg/annotationparser/helpers.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package annotationparser
2+
3+
import (
4+
"bufio"
5+
"io"
6+
"regexp"
7+
)
8+
9+
func scanLineBasedContentForRegexMatches(
10+
content io.Reader,
11+
re *regexp.Regexp,
12+
processResult func(match string, fullLine string, lineNumber int, lineOffset int) error,
13+
) error {
14+
// scan line by line to be able to keep track of line numbers and annotation match byte offsets more easily.
15+
//
16+
// NOTE: docs advise to use a higher-level scanner but returned lines don't contain the line terminators
17+
// and since the terminator can be "\r\n" or "\n" we can't calculate line byte offsets unless we know the exact line.
18+
lineScanner := bufio.NewReader(content)
19+
20+
lineNumber := 1
21+
lineOffset := 0
22+
23+
for {
24+
// line can end in "\n" (Unix convention) "\r\n" (Windows convention).
25+
// both conveniently end in "\n" so that's what we look for.
26+
line, err := lineScanner.ReadBytes('\n')
27+
isEOF := err == io.EOF
28+
if err != nil && !isEOF { // last line has eof set but we need to process its possible content first before stopping
29+
return err
30+
}
31+
32+
annotationsMatch := re.FindStringSubmatch(string(line))
33+
34+
if annotationsMatch != nil {
35+
if err := processResult(annotationsMatch[1], annotationsMatch[0], lineNumber, lineOffset); err != nil {
36+
return err
37+
}
38+
}
39+
40+
lineNumber++
41+
lineOffset += len(line)
42+
43+
if isEOF {
44+
return nil
45+
}
46+
}
47+
}

pkg/annotationparser/parser.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Parses annotations from source code
2+
package annotationparser
3+
4+
import (
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"os"
9+
"regexp"
10+
)
11+
12+
var (
13+
// parses lines that look like `// @:{"key": "value"}`.
14+
// whitespace is allowed to precede `//`.
15+
doubleSlashAnnotationParser = &annotationParserFromRegex{regexp.MustCompile(`^[ \t]*// @:([^\n]+)`)}
16+
17+
DefaultParsersForFileTypes = map[string]Parser{
18+
/* Annotation format for Go
19+
20+
established keys are:
21+
22+
//go:generaste
23+
//go:embed
24+
//go:build
25+
//nolint:<lintername>
26+
//easyjson:json
27+
28+
hence the syntax should be "//" + prefix + ":"
29+
30+
for prefix seems we cannot use symbols like "@" or "_" because `$ goimports` indents to "// ",
31+
so must choose alphanumeric keys.
32+
*/
33+
".go": doubleSlashAnnotationParser,
34+
}
35+
)
36+
37+
// uses a regex to parse annotations from a line
38+
type annotationParserFromRegex struct {
39+
annotationsRe *regexp.Regexp
40+
}
41+
42+
func (g *annotationParserFromRegex) Parse(ctx context.Context, path string, processAnnotation func(Location) error) error {
43+
// unfortunately we need to buffer this as there is no `FindAllStringSubmatch` that operates on streams
44+
file, err := os.Open(path)
45+
if err != nil {
46+
return err
47+
}
48+
defer file.Close()
49+
50+
return scanLineBasedContentForRegexMatches(file, g.annotationsRe, func(match string, fullLine string, lineNumber int, lineOffset int) error {
51+
annots := Annotations{}
52+
53+
if err := json.Unmarshal([]byte(match), &annots); err != nil {
54+
return fmt.Errorf("%w\ninput:\n%s", err, fullLine)
55+
}
56+
57+
if err := processAnnotation(Location{
58+
File: path,
59+
Line: lineNumber,
60+
Offset: lineOffset,
61+
Annotations: annots,
62+
}); err != nil {
63+
return err
64+
}
65+
66+
return nil
67+
})
68+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Parses code bookmarks from project source code's annotations
2+
package bookmarkparser
3+
4+
import (
5+
"context"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"regexp"
10+
11+
"github.com/function61/gokit/encoding/jsonfile"
12+
"github.com/function61/turbobob/pkg/annotationparser"
13+
)
14+
15+
type ID string
16+
17+
var (
18+
// starting with URL safe characters (to easily represent in URLs) restrictive set is always safer to widen than the other way around.
19+
// subset of https://stackoverflow.com/a/695469
20+
idRe = regexp.MustCompile(`^[a-zA-Z0-9\-_]+$`)
21+
)
22+
23+
func (i ID) Validate() error {
24+
if !idRe.MatchString(string(i)) {
25+
return fmt.Errorf("bookmark ID '%s' does not match regex '%s'", i, idRe.String())
26+
}
27+
28+
return nil
29+
}
30+
31+
type Bookmark struct {
32+
ID ID // (hopefully) permanent ID, semantic "symbol" of the bookmark. i.e. favor naming by the concept rather than directly e.g. function name.
33+
File string // path relative to repository root
34+
Line int // line being referenced
35+
}
36+
37+
// @:{"bookmark": "ParseBookmarks"}
38+
func ParseBookmarks(ctx context.Context, dir string, output io.Writer) error {
39+
allAnnotations := []annotationparser.Location{}
40+
41+
// parses annotations, but not yet mapped to bookmarks
42+
if err := annotationparser.ScanDirectoryFilesRecursively(ctx, dir, func(annotation annotationparser.Location) error {
43+
allAnnotations = append(allAnnotations, annotation)
44+
return nil
45+
}, annotationparser.DefaultParsersForFileTypes); err != nil {
46+
return err
47+
}
48+
49+
bookmarks, err := annotationsToBookmarks(allAnnotations)
50+
if err != nil {
51+
return err
52+
}
53+
54+
return jsonfile.Marshal(output, bookmarks)
55+
}
56+
57+
func annotationsToBookmarks(annotations []annotationparser.Location) ([]Bookmark, error) {
58+
withErr := func(err error) ([]Bookmark, error) { return nil, fmt.Errorf("annotationsToBookmarks: %w", err) }
59+
60+
bookmarks := []Bookmark{}
61+
for _, annotation := range annotations {
62+
bookmarkID, hasBookmark := annotation.Annotations["bookmark"]
63+
if !hasBookmark {
64+
continue
65+
}
66+
67+
id_, ok := bookmarkID.(string)
68+
if !ok {
69+
return withErr(errors.New("bookmark ID not string"))
70+
}
71+
72+
id := ID(id_)
73+
74+
if err := id.Validate(); err != nil {
75+
return withErr(err)
76+
}
77+
78+
bookmarks = append(bookmarks, Bookmark{
79+
ID: id,
80+
File: annotation.File,
81+
Line: annotation.LineTarget(),
82+
})
83+
}
84+
85+
return bookmarks, nil
86+
}

pkg/bookmarkparser/cli.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package bookmarkparser
2+
3+
import (
4+
"context"
5+
"log"
6+
"os"
7+
8+
"github.com/function61/gokit/app/cli"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
func Entrypoint() *cobra.Command {
13+
return &cobra.Command{
14+
Use: "bookmarks-build",
15+
Short: "Build code bookmarks database from current directory",
16+
Args: cobra.NoArgs,
17+
Run: cli.RunnerNoArgs(func(ctx context.Context, _ *log.Logger) error {
18+
return ParseBookmarks(ctx, ".", os.Stdout)
19+
}),
20+
}
21+
}

0 commit comments

Comments
 (0)