Skip to content

Commit

Permalink
bake: initial release
Browse files Browse the repository at this point in the history
  • Loading branch information
DavidGamba committed Jul 17, 2024
1 parent 45be91b commit 8a907a0
Show file tree
Hide file tree
Showing 25 changed files with 2,217 additions and 0 deletions.
363 changes: 363 additions & 0 deletions bake/LICENSE

Large diffs are not rendered by default.

161 changes: 161 additions & 0 deletions bake/README.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
= Bake

Go Build + Something like Make = Bake ¯\_(ツ)_/¯ 🤷
Bake is a Make like tool that allows you to define and run tasks defined in Go code under a `bakefiles/` dir.
* Tasks have a fully featured argument parser.
* It allows you to see the description of the tasks and subtasks and autocomplete them and their options.
* It also bundles a DAG runner that allows you to make tasks depend on other tasks.
* Built-in cancellation context for tasks.
== Getting Started
In any directory, run: `bake _bake init` to create a `bakefiles/` directory with an empty project.
Then run: `bake` to see the available tasks.
== Install
* Install using go:
+
Install the binary into your `~/go/bin`:
+
----
go install github.com/DavidGamba/dgtools/bake@latest
----
+
Then setup the completion.
+
For bash:
+
----
complete -o default -C bake bake
----
+
For zsh:
+
[source, zsh]
----
export ZSHELL="true"
autoload -U +X compinit && compinit
autoload -U +X bashcompinit && bashcompinit
complete -o default -C bake bake
----
=== Gitignore
Add the following to your global gitignore file:
.~/.gitignore
----
**/bake/bake
**/bake/generated_bake.go
----
== Example Task
NOTE: A more in depth example can be found https://github.com/DavidGamba/go-getoptions/blob/bake/bake/examples/website/README.adoc[here].
Copy the following into a new `bakefiles/main.go` file after running `bake _bake init`:
[source, go]
----
package main
import (
"context"
"fmt"
"github.com/DavidGamba/go-getoptions"
)
// say:hello - This is a greeting
func Hello(opt *getoptions.GetOpt) getoptions.CommandFn {
var lang string
opt.StringVar(&lang, "lang", "en", opt.ValidValues("en", "es"))
return func(ctx context.Context, opt *getoptions.GetOpt, args []string) error {
Logger.Printf("Running say:hello\n")
switch lang {
case "en":
fmt.Println("Hello")
case "es":
fmt.Println("Hola")
}
return nil
}
}
----
Run it:
----
$ bake say hello
2023/04/10 14:58:34 Running Hello
Hello
$ bake say hello --lang=es
2023/04/10 14:58:38 Running Hello
Hola
----
== How does it work?
Bake is a Make like tool that allows you to define and run tasks defined in Go code under a `bakefiles/` dir.
First it searches for `bakefiles/` inside the current directory,
next it searches to see if the current directory is named `bakefiles/`,
finally it searches for a `bakefiles/` directory in the parent directories.
This allows to run bake from anywhere in the repo.
Once a `bakefiles/` dir is found, it will parse the AST of the Go files in that directory to find functions that match the proper signature.
It will then generate an entry point file (`generated_bake.go`) that uses those functions, this file is auto-generated any time your source code changes.
Finally it will compile the Go binary and run it.
Having a go binary that bake runs allows you to debug your code directly without having to worry about bake's internals.
The binary is only recompiled if the source code is changed (using https://github.com/DavidGamba/dgtools/tree/master/fsmodtime[fsmodtime]).
The bake binary loads your functions as tasks and subtasks and makes them and their options available for completion.
The bake Task signature is `func(opt *getoptions.GetOpt) getoptions.CommandFn`.
The functions are loaded as https://github.com/DavidGamba/go-getoptions/tree/master[go-getoptions] commands and subcommands, by parsing the comment description.
For example:
[source,go]
----
// say:hello - This is a greeting
func Hello(opt *getoptions.GetOpt) getoptions.CommandFn {
return func(ctx context.Context, opt *getoptions.GetOpt, args []string) error {
return nil
}
}
----
If there is no comment found for the function, the function name will be automatically converted to kebab case.
The above function will be generate two commands, one for `say` and one for `hello`.
The description for the `hello` command will be `This is a greeting`.
Since the tasks are added to the bake command's `go-getoptions` instance, completions are automatically generated.
It also adds the task to the global task map, the task will automatically be added as `say:hello`.
This allows to generate custom task graphs using https://github.com/DavidGamba/go-getoptions/blob/master/dag/README.adoc[go-getoptions DAG].
== Debugging
To debug your program go to the `bakefiles/` directory and run `bake` and you should see the `bake` binary.
Set your IDE Debugger to run `./bake` with the proper arguments for your task.
To print `bake` traces, set the env var `BAKE_TRACE=true`.
== ROADMAP
* Currently not all `go-getoptions` types are supported.
* Helper for automated cancellation on timeout when passing -t flag.
* Ensure exit codes get passed through.
126 changes: 126 additions & 0 deletions bake/ast.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// This file is part of bake.
//
// Copyright (C) 2023-2024 David Gamba Rios
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

package main

import (
"bytes"
"fmt"
"go/ast"
"go/parser"
"go/printer"
"go/token"
"iter"
"strings"

"golang.org/x/tools/go/packages"
)

type ParsedFile struct {
file string
fset *token.FileSet
f *ast.File
}

// Requires GOEXPERIMENT=rangefunc
func parsedFiles(dir string) iter.Seq2[ParsedFile, error] {
return func(yield func(ParsedFile, error) bool) {
cfg := &packages.Config{Mode: packages.NeedFiles | packages.NeedSyntax, Dir: dir}
pkgs, err := packages.Load(cfg, ".")
if err != nil {
yield(ParsedFile{}, fmt.Errorf("failed to load packages: %w", err))
return
}
for _, pkg := range pkgs {
// Logger.Println(pkg.ID, pkg.GoFiles)
for _, file := range pkg.GoFiles {
if strings.Contains(file, "generated") {
continue
}
p := ParsedFile{}
// Logger.Printf("file: %s\n", file)
// parse file
fset := token.NewFileSet()
fset.AddFile(file, fset.Base(), len(file))
p.file = file
p.fset = fset
f, err := parser.ParseFile(fset, file, nil, parser.ParseComments)
if err != nil {
yield(p, fmt.Errorf("failed to parse file: %w", err))
return
}
p.f = f
if !yield(p, nil) {
return
}
}
}
}
}

type FnDecl struct {
Name string // function name
Description string

Node ast.Node
ParsedFile ParsedFile
Type string
}

// Requires GOEXPERIMENT=rangefunc
func AstFns(dir string) iter.Seq2[FnDecl, error] {
return func(yield func(FnDecl, error) bool) {
for p, err := range parsedFiles(dir) {
if err != nil {
yield(FnDecl{}, err)
return
}
fnDecl := FnDecl{ParsedFile: p}

doneYield := false
// Iterate through every node in the file
ast.Inspect(p.f, func(n ast.Node) bool {
if doneYield {
return false
}
fnDecl.Node = n
switch x := n.(type) {
// Check function declarations for exported functions
case *ast.FuncDecl:
if x.Name.IsExported() {
fnDecl.Name = x.Name.Name
fnDecl.Description = x.Doc.Text()
var buf bytes.Buffer
printer.Fprint(&buf, p.fset, x.Type)
fnDecl.Type = buf.String()
if !yield(fnDecl, nil) {
doneYield = true
return false
}
}
}
return true
})

if doneYield {
return
}
}
}
}

func PrintFuncDecl(dir string) error {
for fnDecl, err := range AstFns(dir) {
if err != nil {
return err
}
fmt.Printf("file: %s\n\tname: %s\n\ttype: %s\n\tdesc: %s\n", fnDecl.ParsedFile.file, fnDecl.Name, fnDecl.Type, fnDecl.Description)
}

return nil
}
Loading

0 comments on commit 8a907a0

Please sign in to comment.