Skip to content
Open
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
18 changes: 11 additions & 7 deletions internal/devbox/docgen/docgen.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,18 @@ func GenerateReadme(
return err
}

cfg := devbox.Config()
scripts := cfg.Scripts().
WithRelativePaths(devbox.ProjectDir()).
InOrder(cfg.ScriptOrder())

return tmpl.Execute(f, map[string]any{
"Name": devbox.Config().Root.Name,
"Description": devbox.Config().Root.Description,
"Scripts": devbox.Config().Scripts().
WithRelativePaths(devbox.ProjectDir()),
"EnvVars": devbox.Config().Env(),
"InitHook": devbox.Config().InitHook(),
"Packages": devbox.TopLevelPackages(),
"Name": cfg.Root.Name,
"Description": cfg.Root.Description,
"Scripts": scripts,
"EnvVars": cfg.Env(),
"InitHook": cfg.InitHook(),
"Packages": devbox.TopLevelPackages(),
// TODO add includes
})
}
Expand Down
10 changes: 5 additions & 5 deletions internal/devbox/docgen/readme.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ devbox run <script>
{{- if .Scripts }}
## Scripts
Scripts are custom commands that can be run using this project's environment. This project has the following scripts:
{{ range $name, $_ := .Scripts }}
* [{{ $name }}](#devbox-run-{{ $name }})
{{ range .Scripts }}
* [{{ .Name }}](#devbox-run-{{ .Name }})
{{- end }}
{{ end }}

Expand Down Expand Up @@ -61,13 +61,13 @@ on `devbox shell` and on `devbox run`.

{{- if .Scripts }}
## Script Details
{{ range $name, $commands := .Scripts }}
### devbox run {{ $name }}
{{ range .Scripts }}
### devbox run {{ .Name }}
{{- if .Comments }}
{{ .Comments }}
{{- end }}
```sh
{{ $commands }}
{{ .Commands }}
```
&ensp;
{{ end }}
Expand Down
13 changes: 13 additions & 0 deletions internal/devconfig/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,19 @@ func (c *Config) Scripts() configfile.Scripts {
return scripts
}

// ScriptOrder returns script names in the order they are defined, with scripts
// from included plugins first (matching Scripts' merge precedence) followed by
// the root config's scripts. Duplicate names are de-duplicated by the consumer
// (Scripts.InOrder).
func (c *Config) ScriptOrder() []string {
var order []string
for _, i := range c.included {
order = append(order, i.ScriptOrder()...)
}
order = append(order, c.Root.ScriptOrder()...)
return order
}

func (c *Config) Hash() (string, error) {
data := []byte{}
for _, i := range c.included {
Expand Down
28 changes: 28 additions & 0 deletions internal/devconfig/configfile/ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,34 @@ func (c *configAST) appendStringSliceField(name, fieldName string, fieldValues [
c.root.Format()
}

// objectKeysInOrder walks the AST to the object at the given path and returns
// its member names in the order they appear in the file. It returns nil if the
// path doesn't resolve to an object (for example, when the field is missing).
func (c *configAST) objectKeysInOrder(path ...string) []string {
elem := c.root
for _, key := range path {
obj, ok := elem.Value.(*hujson.Object)
if !ok {
return nil
}
i := c.memberIndex(obj, key)
if i == -1 {
return nil
}
elem = obj.Members[i].Value
}

obj, ok := elem.Value.(*hujson.Object)
if !ok {
return nil
}
names := make([]string, 0, len(obj.Members))
for i := range obj.Members {
names = append(names, obj.Members[i].Name.Value.(hujson.Literal).String())
}
return names
}

func (c *configAST) beforeComment(path ...any) []byte {
elem := c.root
for _, pathItem := range path {
Expand Down
73 changes: 73 additions & 0 deletions internal/devconfig/configfile/scripts.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package configfile

import (
"slices"
"strings"

"go.jetify.com/devbox/internal/devbox/shellcmd"
Expand All @@ -13,6 +14,78 @@ type script struct {

type Scripts map[string]*script

// ScriptWithName pairs a script with its name so callers can iterate over
// scripts in a deterministic order. Scripts are stored in a map, and Go's
// text/template ranges over map keys in sorted (alphabetical) order, which
// doesn't match the order scripts are defined in devbox.json. This type is
// used when generating documentation so the output preserves the user's
// ordering.
type ScriptWithName struct {
Name string
Commands *shellcmd.Commands
Comments string
}

// ScriptOrder returns the names of the scripts in the order they appear in the
// devbox.json file. Names that can't be determined from the source file (for
// example, when the config wasn't parsed from a file) are omitted; callers
// should treat a missing name as "order unknown".
func (c *ConfigFile) ScriptOrder() []string {
if c == nil || c.ast == nil {
return nil
}
return c.ast.objectKeysInOrder("shell", "scripts")
}

// InOrder returns the scripts as a slice ordered by the given names. Any
// scripts not present in order (or when order is nil) are appended in
// alphabetical order so the result stays deterministic.
//
// order may contain a name more than once when it's built by concatenating
// multiple sources (e.g. included configs followed by the root config). In
// that case only the last occurrence is used, so a script overridden by a
// later definition appears at that definition's position. This matches the
// merge precedence that produced its value in s (later definitions win).
func (s Scripts) InOrder(order []string) []ScriptWithName {
result := make([]ScriptWithName, 0, len(s))
seen := make(map[string]bool, len(s))
add := func(name string) {
sc := s[name]
result = append(result, ScriptWithName{
Name: name,
Commands: &sc.Commands,
Comments: sc.Comments,
})
seen[name] = true
}

lastIndex := make(map[string]int, len(order))
for i, name := range order {
lastIndex[name] = i
}
for i, name := range order {
if lastIndex[name] != i {
continue
}
if _, ok := s[name]; ok && !seen[name] {
add(name)
}
}

Comment thread
mikeland73 marked this conversation as resolved.
rest := make([]string, 0, len(s))
for name := range s {
if !seen[name] {
rest = append(rest, name)
}
}
slices.Sort(rest)
for _, name := range rest {
add(name)
}

return result
}

func (c *ConfigFile) Scripts() Scripts {
if c == nil || c.Shell == nil {
return nil
Expand Down
113 changes: 113 additions & 0 deletions internal/devconfig/configfile/scripts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package configfile

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// scriptNames returns just the names of the ordered scripts, for convenient
// assertions.
func scriptNames(scripts []ScriptWithName) []string {
names := make([]string, 0, len(scripts))
for _, s := range scripts {
names = append(names, s.Name)
}
return names
}

func TestScriptsInOrderPreservesSourceOrder(t *testing.T) {
config := []byte(`{
"shell": {
"scripts": {
"step-one": "echo one",
"step-two": "echo two",
"step-three": "echo three",
"step-four": "echo four"
}
}
}`)

cfg, err := LoadBytes(config)
require.NoError(t, err)

ordered := cfg.Scripts().InOrder(cfg.ScriptOrder())
assert.Equal(t,
[]string{"step-one", "step-two", "step-three", "step-four"},
scriptNames(ordered),
)
}

func TestScriptsInOrderFallsBackToAlphabetical(t *testing.T) {
// When no order is provided (e.g. the config wasn't parsed from a file),
// the result should be deterministic (alphabetical) rather than random
// map order.
scripts := Scripts{
"build": &script{},
"test": &script{},
"clean": &script{},
}

ordered := scripts.InOrder(nil)
assert.Equal(t, []string{"build", "clean", "test"}, scriptNames(ordered))
}

func TestScriptsInOrderAppendsUnknownScripts(t *testing.T) {
// Scripts present in the map but missing from the order slice should be
// appended in alphabetical order, with no duplicates.
scripts := Scripts{
"first": &script{},
"second": &script{},
"zeta": &script{},
"alpha": &script{},
}

ordered := scripts.InOrder([]string{"first", "second"})
assert.Equal(t,
[]string{"first", "second", "alpha", "zeta"},
scriptNames(ordered),
)
}

func TestScriptsInOrderDedupesKeepingLastOccurrence(t *testing.T) {
// When a script name appears more than once in order (e.g. defined in
// both an included config and the root config), it should appear once, at
// the position of its last occurrence — matching the merge precedence
// where the later (root) definition wins.
scripts := Scripts{
"shared": &script{},
"plugin-only": &script{},
"root-only": &script{},
}

// Simulates: included config order ["shared", "plugin-only"] followed by
// root config order ["root-only", "shared"]. "shared" is overridden by
// root, so it should follow root-only rather than lead.
order := []string{"shared", "plugin-only", "root-only", "shared"}

ordered := scripts.InOrder(order)
assert.Equal(t,
[]string{"plugin-only", "root-only", "shared"},
scriptNames(ordered),
)
}

func TestScriptsInOrderCarriesCommands(t *testing.T) {
config := []byte(`{
"shell": {
"scripts": {
"greet": ["echo hello", "echo world"]
}
}
}`)

cfg, err := LoadBytes(config)
require.NoError(t, err)

ordered := cfg.Scripts().InOrder(cfg.ScriptOrder())
require.Len(t, ordered, 1)
assert.Equal(t, "greet", ordered[0].Name)
require.NotNil(t, ordered[0].Commands)
assert.Equal(t, "echo hello\necho world", ordered[0].Commands.String())
}
Loading