Skip to content

feat(examples): add linting to all examples #12595

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Mar 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ else
endif
.PHONY: fmt/shfmt

lint: lint/shellcheck lint/go lint/ts lint/helm lint/site-icons
lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons
.PHONY: lint

lint/site-icons:
Expand All @@ -447,6 +447,10 @@ lint/go:
golangci-lint run
.PHONY: lint/go

lint/examples:
go run ./scripts/examplegen/main.go -lint
.PHONY: lint/examples

# Use shfmt to determine the shell files, takes editorconfig into consideration.
lint/shellcheck: $(SHELL_SRC_FILES)
echo "--- shellcheck"
Expand Down
2 changes: 1 addition & 1 deletion examples/templates/incus/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
display_name: Incus System Container with Docker
description: Develop in an Incus System Container with Docker using incus
icon: /icon/lxc.svg
icon: ../../../site/static/icon/lxc.svg
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😅

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be fair, both forms work. But all our other examples/templates use this relative form, and it's handy for confirming that the exact path exists.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, +1 for enforced consistency!

maintainer_github: coder
verified: true
tags: [local, incus, lxc, lxd]
Expand Down
225 changes: 151 additions & 74 deletions scripts/examplegen/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ package main
import (
"bytes"
"encoding/json"
"errors"
"flag"
"fmt"
"go/parser"
"go/token"
"io"
"io/fs"
"os"
"path"
Expand All @@ -24,122 +27,196 @@ const (
)

func main() {
if err := run(); err != nil {
panic(err)
lint := flag.Bool("lint", false, "Lint **all** the examples instead of generating the examples.gen.json file")
flag.Parse()

if err := run(*lint); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "error: %+v\n", err)
os.Exit(1)
}
}

func run() error {
//nolint:revive // This is a script, not a library.
func run(lint bool) error {
fset := token.NewFileSet()
src, err := parser.ParseFile(fset, filepath.Join(examplesDir, examplesSrc), nil, parser.ParseComments)
if err != nil {
return err
}

projectFS := os.DirFS(".")
examplesFS := os.DirFS(examplesDir)

var paths []string
for _, comment := range src.Comments {
for _, line := range comment.List {
if s, ok := parseEmbedTag(line.Text); ok && !strings.HasSuffix(s, ".json") {
paths = append(paths, s)
if lint {
files, err := fs.ReadDir(examplesFS, "templates")
if err != nil {
return err
}

for _, f := range files {
if !f.IsDir() {
continue
}
paths = append(paths, filepath.Join("templates", f.Name()))
}
} else {
for _, comment := range src.Comments {
for _, line := range comment.List {
if s, ok := parseEmbedTag(line.Text); ok && !strings.HasSuffix(s, ".json") {
paths = append(paths, s)
}
}
}
}

var examples []codersdk.TemplateExample
files := os.DirFS(examplesDir)
var errs []error
for _, name := range paths {
dir, err := fs.Stat(files, name)
te, err := parseTemplateExample(projectFS, examplesFS, name)
if err != nil {
return err
}
if !dir.IsDir() {
errs = append(errs, err)
continue
}
exampleID := dir.Name()
// Each one of these is a example!
readme, err := fs.ReadFile(files, path.Join(name, "README.md"))
if err != nil {
return xerrors.Errorf("example %q does not contain README.md", exampleID)
if te != nil {
examples = append(examples, *te)
}
}

frontMatter, err := pageparser.ParseFrontMatterAndContent(bytes.NewReader(readme))
if len(errs) > 0 {
return xerrors.Errorf("parse failed: %w", errors.Join(errs...))
}

var w io.Writer = os.Stdout
if lint {
w = io.Discard
}

_, err = fmt.Fprint(w, "// Code generated by examplegen. DO NOT EDIT.\n")
if err != nil {
return err
}

enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(examples)
}

func parseTemplateExample(projectFS, examplesFS fs.FS, name string) (te *codersdk.TemplateExample, err error) {
var errs []error
defer func() {
if err != nil {
return xerrors.Errorf("parse example %q front matter: %w", exampleID, err)
errs = append([]error{err}, errs...)
}

nameRaw, exists := frontMatter.FrontMatter["display_name"]
if !exists {
return xerrors.Errorf("example %q front matter does not contain name", exampleID)
if len(errs) > 0 {
err = xerrors.Errorf("example %q has errors", name)
for _, e := range errs {
err = errors.Join(err, e)
}
}
}()

name, valid := nameRaw.(string)
if !valid {
return xerrors.Errorf("example %q name isn't a string", exampleID)
}
dir, err := fs.Stat(examplesFS, name)
if err != nil {
return nil, err
}
if !dir.IsDir() {
//nolint:nilnil // This is a script, not a library.
return nil, nil
}

descriptionRaw, exists := frontMatter.FrontMatter["description"]
if !exists {
return xerrors.Errorf("example %q front matter does not contain name", exampleID)
}
exampleID := dir.Name()
// Each one of these is a example!
readme, err := fs.ReadFile(examplesFS, path.Join(name, "README.md"))
if err != nil {
return nil, xerrors.New("missing README.md")
}

description, valid := descriptionRaw.(string)
if !valid {
return xerrors.Errorf("example %q description isn't a string", exampleID)
}
frontMatter, err := pageparser.ParseFrontMatterAndContent(bytes.NewReader(readme))
if err != nil {
return nil, xerrors.Errorf("parse front matter: %w", err)
}

tags := []string{}
tagsRaw, exists := frontMatter.FrontMatter["tags"]
if exists {
tagsI, valid := tagsRaw.([]interface{})
if !valid {
return xerrors.Errorf("example %q tags isn't a slice: type %T", exampleID, tagsRaw)
}
// Make sure validation here is in sync with requirements for
// coder/registry.
displayName, err := getString(frontMatter.FrontMatter, "display_name")
if err != nil {
errs = append(errs, err)
}

description, err := getString(frontMatter.FrontMatter, "description")
if err != nil {
errs = append(errs, err)
}

_, err = getString(frontMatter.FrontMatter, "maintainer_github")
if err != nil {
errs = append(errs, err)
}

tags := []string{}
tagsRaw, exists := frontMatter.FrontMatter["tags"]
if exists {
tagsI, valid := tagsRaw.([]interface{})
if !valid {
errs = append(errs, xerrors.Errorf("tags isn't a slice: type %T", tagsRaw))
} else {
for _, tagI := range tagsI {
tag, valid := tagI.(string)
if !valid {
return xerrors.Errorf("example %q tag isn't a string: type %T", exampleID, tagI)
errs = append(errs, xerrors.Errorf("tag isn't a string: type %T", tagI))
continue
}
tags = append(tags, tag)
}
}
}

var icon string
iconRaw, exists := frontMatter.FrontMatter["icon"]
if exists {
icon, valid = iconRaw.(string)
if !valid {
return xerrors.Errorf("example %q icon isn't a string", exampleID)
}
icon, err = filepath.Rel("../site/static/", filepath.Join(examplesDir, name, icon))
if err != nil {
return xerrors.Errorf("example %q icon is not in site/static: %w", exampleID, err)
}
// The FE needs a static path!
icon = "/" + icon
var icon string
icon, err = getString(frontMatter.FrontMatter, "icon")
if err != nil {
errs = append(errs, err)
} else {
cleanPath := filepath.Clean(filepath.Join(examplesDir, name, icon))
_, err := fs.Stat(projectFS, cleanPath)
if err != nil {
errs = append(errs, xerrors.Errorf("icon does not exist: %w", err))
}
if !strings.HasPrefix(cleanPath, filepath.Join("site", "static")) {
errs = append(errs, xerrors.Errorf("icon is not in site/static/: %q", icon))
}
icon, err = filepath.Rel(filepath.Join("site", "static"), cleanPath)
if err != nil {
errs = append(errs, xerrors.Errorf("cannot make icon relative to site/static: %w", err))
}
}

examples = append(examples, codersdk.TemplateExample{
ID: exampleID,
Name: name,
Description: description,
Icon: icon,
Tags: tags,
Markdown: string(frontMatter.Content),

// URL is set by examples/examples.go.
})
if len(errs) > 0 {
return nil, xerrors.New("front matter validation failed")
}

w := os.Stdout
return &codersdk.TemplateExample{
ID: exampleID,
Name: displayName,
Description: description,
Icon: "/" + icon, // The FE needs a static path!
Tags: tags,
Markdown: string(frontMatter.Content),

_, err = fmt.Fprint(w, "// Code generated by examplegen. DO NOT EDIT.\n")
if err != nil {
return err
}
// URL is set by examples/examples.go.
}, nil
}

enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(examples)
func getString(m map[string]any, key string) (string, error) {
v, ok := m[key]
if !ok {
return "", xerrors.Errorf("front matter does not contain %q", key)
}
vv, ok := v.(string)
if !ok {
return "", xerrors.Errorf("%q isn't a string", key)
}
return vv, nil
}

func parseEmbedTag(s string) (string, bool) {
Expand Down