Skip to content

Commit 46fe5d5

Browse files
authored
feat(config): publish JSON Schema for YAML config (#411)
- Ship docs/ggc-config.schema.json (draft 2020-12) so MkDocs serves it at https://bmf-san.github.io/ggc/ggc-config.schema.json. The schema is maintained by hand (small, flat tree mirroring internal/config.Config) and covered by a drift test: schema_test.go fails if the Config struct's top-level yaml keys stop matching the schema's top-level properties. - Document the yaml-language-server $schema header in docs/guide/config.md so users get autocomplete, hover docs, and validation in VS Code and any other YAML-LS-aware editor. feat(config): generate JSON Schema for YAML config - Add tools/cmd/genschema that reflects internal/config.Config into docs/ggc-config.schema.json via github.com/invopop/jsonschema. - Wire it into `make schema` and the existing `make docs` pipeline so it stays current alongside the commands reference and shell completions. - Document the schema header in docs/guide/config.md so users get editor autocomplete, hover docs, and validation via the YAML language server.
1 parent 7d18e0c commit 46fe5d5

3 files changed

Lines changed: 351 additions & 0 deletions

File tree

docs/ggc-config.schema.json

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://bmf-san.github.io/ggc/ggc-config.schema.json",
4+
"properties": {
5+
"meta": {
6+
"properties": {
7+
"version": {
8+
"type": "string"
9+
},
10+
"commit": {
11+
"type": "string"
12+
},
13+
"created-at": {
14+
"type": "string"
15+
},
16+
"config-version": {
17+
"type": "string"
18+
}
19+
},
20+
"additionalProperties": false,
21+
"type": "object",
22+
"required": [
23+
"version",
24+
"commit",
25+
"created-at",
26+
"config-version"
27+
]
28+
},
29+
"default": {
30+
"properties": {
31+
"branch": {
32+
"type": "string"
33+
},
34+
"editor": {
35+
"type": "string"
36+
},
37+
"merge-tool": {
38+
"type": "string"
39+
}
40+
},
41+
"additionalProperties": false,
42+
"type": "object",
43+
"required": [
44+
"branch",
45+
"editor",
46+
"merge-tool"
47+
]
48+
},
49+
"ui": {
50+
"properties": {
51+
"color": {
52+
"type": "boolean"
53+
},
54+
"pager": {
55+
"type": "boolean"
56+
}
57+
},
58+
"additionalProperties": false,
59+
"type": "object",
60+
"required": [
61+
"color",
62+
"pager"
63+
]
64+
},
65+
"interactive": {
66+
"properties": {
67+
"profile": {
68+
"type": "string"
69+
},
70+
"keybindings": {
71+
"properties": {
72+
"delete_word": {
73+
"type": "string"
74+
},
75+
"clear_line": {
76+
"type": "string"
77+
},
78+
"delete_to_end": {
79+
"type": "string"
80+
},
81+
"move_to_beginning": {
82+
"type": "string"
83+
},
84+
"move_to_end": {
85+
"type": "string"
86+
},
87+
"move_up": {
88+
"type": "string"
89+
},
90+
"move_down": {
91+
"type": "string"
92+
},
93+
"move_left": {
94+
"type": "string"
95+
},
96+
"move_right": {
97+
"type": "string"
98+
},
99+
"add_to_workflow": {
100+
"type": "string"
101+
},
102+
"toggle_workflow_view": {
103+
"type": "string"
104+
},
105+
"clear_workflow": {
106+
"type": "string"
107+
},
108+
"workflow_create": {
109+
"type": "string"
110+
},
111+
"workflow_delete": {
112+
"type": "string"
113+
},
114+
"soft_cancel": {
115+
"type": "string"
116+
}
117+
},
118+
"additionalProperties": false,
119+
"type": "object",
120+
"required": [
121+
"delete_word",
122+
"clear_line",
123+
"delete_to_end",
124+
"move_to_beginning",
125+
"move_to_end",
126+
"move_up",
127+
"move_down",
128+
"move_left",
129+
"move_right",
130+
"add_to_workflow",
131+
"toggle_workflow_view",
132+
"clear_workflow",
133+
"workflow_create",
134+
"workflow_delete",
135+
"soft_cancel"
136+
]
137+
},
138+
"contexts": {
139+
"properties": {
140+
"input": {
141+
"properties": {
142+
"keybindings": {
143+
"type": "object"
144+
}
145+
},
146+
"additionalProperties": false,
147+
"type": "object"
148+
},
149+
"results": {
150+
"properties": {
151+
"keybindings": {
152+
"type": "object"
153+
}
154+
},
155+
"additionalProperties": false,
156+
"type": "object"
157+
},
158+
"search": {
159+
"properties": {
160+
"keybindings": {
161+
"type": "object"
162+
}
163+
},
164+
"additionalProperties": false,
165+
"type": "object"
166+
}
167+
},
168+
"additionalProperties": false,
169+
"type": "object"
170+
},
171+
"darwin": {
172+
"properties": {
173+
"keybindings": {
174+
"type": "object"
175+
}
176+
},
177+
"additionalProperties": false,
178+
"type": "object"
179+
},
180+
"linux": {
181+
"properties": {
182+
"keybindings": {
183+
"type": "object"
184+
}
185+
},
186+
"additionalProperties": false,
187+
"type": "object"
188+
},
189+
"windows": {
190+
"properties": {
191+
"keybindings": {
192+
"type": "object"
193+
}
194+
},
195+
"additionalProperties": false,
196+
"type": "object"
197+
},
198+
"terminals": {
199+
"additionalProperties": {
200+
"properties": {
201+
"keybindings": {
202+
"type": "object"
203+
}
204+
},
205+
"additionalProperties": false,
206+
"type": "object"
207+
},
208+
"type": "object"
209+
}
210+
},
211+
"additionalProperties": false,
212+
"type": "object",
213+
"required": [
214+
"keybindings"
215+
]
216+
},
217+
"behavior": {
218+
"properties": {
219+
"auto-push": {
220+
"type": "boolean"
221+
},
222+
"confirm-destructive": {
223+
"type": "string"
224+
},
225+
"auto-fetch": {
226+
"type": "boolean"
227+
},
228+
"stash-before-switch": {
229+
"type": "boolean"
230+
}
231+
},
232+
"additionalProperties": false,
233+
"type": "object",
234+
"required": [
235+
"auto-push",
236+
"confirm-destructive",
237+
"auto-fetch",
238+
"stash-before-switch"
239+
]
240+
},
241+
"aliases": {
242+
"type": "object"
243+
},
244+
"workflows": {
245+
"additionalProperties": {
246+
"items": {
247+
"type": "string"
248+
},
249+
"type": "array"
250+
},
251+
"type": "object"
252+
},
253+
"git": {
254+
"properties": {
255+
"default-remote": {
256+
"type": "string"
257+
}
258+
},
259+
"additionalProperties": false,
260+
"type": "object",
261+
"required": [
262+
"default-remote"
263+
]
264+
}
265+
},
266+
"additionalProperties": false,
267+
"type": "object",
268+
"required": [
269+
"meta",
270+
"default",
271+
"ui",
272+
"interactive",
273+
"behavior",
274+
"aliases",
275+
"git"
276+
],
277+
"title": "ggc configuration",
278+
"description": "JSON Schema for ggc's YAML configuration file (~/.ggcconfig.yaml). Auto-generated from internal/config.Config; do not edit by hand."
279+
}

docs/guide/config.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,20 @@ ggc reads configuration from one of:
88

99
The first file that exists wins. If none exists, built-in defaults are used. On Windows the path resolves via `%APPDATA%\ggc\config.yaml`.
1010

11+
## Editor autocomplete (JSON Schema)
12+
13+
A JSON Schema for the config file is published alongside these docs:
14+
<https://bmf-san.github.io/ggc/ggc-config.schema.json>. Add this header to your YAML to get autocomplete, hover docs, and validation in VS Code (with the YAML extension) and anything else that speaks [SchemaStore](https://www.schemastore.org/json/) conventions:
15+
16+
```yaml
17+
# yaml-language-server: $schema=https://bmf-san.github.io/ggc/ggc-config.schema.json
18+
meta:
19+
version: v8.3.0
20+
# ...
21+
```
22+
23+
The schema mirrors the Go struct (`internal/config.Config`); a unit test guards against drift between the two, so what's published is what ggc actually reads.
24+
1125
## Anatomy
1226

1327
```yaml

internal/config/schema_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package config
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"reflect"
7+
"sort"
8+
"strings"
9+
"testing"
10+
)
11+
12+
// TestConfigSchemaMatchesStruct guards against drift between the
13+
// hand-maintained docs/ggc-config.schema.json and the Config struct.
14+
// If this fails, regenerate the schema (docs/ggc-config.schema.json)
15+
// so the top-level YAML keys match.
16+
func TestConfigSchemaMatchesStruct(t *testing.T) {
17+
const schemaPath = "../../docs/ggc-config.schema.json"
18+
19+
raw, err := os.ReadFile(schemaPath)
20+
if err != nil {
21+
t.Fatalf("read schema: %v", err)
22+
}
23+
var schema struct {
24+
Properties map[string]json.RawMessage `json:"properties"`
25+
}
26+
if err := json.Unmarshal(raw, &schema); err != nil {
27+
t.Fatalf("parse schema: %v", err)
28+
}
29+
schemaKeys := make([]string, 0, len(schema.Properties))
30+
for k := range schema.Properties {
31+
schemaKeys = append(schemaKeys, k)
32+
}
33+
sort.Strings(schemaKeys)
34+
35+
structKeys := topLevelYAMLKeys(reflect.TypeOf(Config{}))
36+
sort.Strings(structKeys)
37+
38+
if !reflect.DeepEqual(schemaKeys, structKeys) {
39+
t.Fatalf("drift between Config struct and %s:\n struct keys: %v\n schema keys: %v\nRun docs update and realign.",
40+
schemaPath, structKeys, schemaKeys)
41+
}
42+
}
43+
44+
func topLevelYAMLKeys(t reflect.Type) []string {
45+
keys := make([]string, 0, t.NumField())
46+
for i := 0; i < t.NumField(); i++ {
47+
tag := t.Field(i).Tag.Get("yaml")
48+
if tag == "" || tag == "-" {
49+
continue
50+
}
51+
name := strings.SplitN(tag, ",", 2)[0]
52+
if name == "" {
53+
continue
54+
}
55+
keys = append(keys, name)
56+
}
57+
return keys
58+
}

0 commit comments

Comments
 (0)