Skip to content

Commit c3fd4dc

Browse files
authored
feat(fn): add --help, --doc, and standalone file mode to AsMain (#752)
* feat(fn): add --help, --doc, and standalone file mode to AsMain Extend fn.AsMain with functional options to support: - --help: renders human-readable docs from embedded README markers - --doc: outputs machine-readable JSON for catalog tooling - Standalone file mode: accepts positional file args for local debugging New fn.WithDocs(readme, meta) option registers embedded content. Existing callers with no options are unaffected (backward-compatible). Adds internal/docs package with: - ParseMarkers: extracts mdtogo Short/Long/Examples sections - ParseMetadata: parses metadata.yaml content - RenderHelp/RenderDoc: formats output for --help and --doc Includes property-based tests (rapid) and unit tests for all new code. Signed-off-by: Fiachra Corcoran <fiachra.corcoran@est.tech> * chore: apply go fix modernization Mechanical changes from 'go fix ./...': - interface{} → any (Go 1.18+ type alias) - reflect.Ptr → reflect.Pointer (deprecated constant) - strings.Index+slice → strings.Cut (Go 1.18+ idiom) Signed-off-by: Fiachra Corcoran <fiachra.corcoran@est.tech> * chore: gitignore rapid testdata failure files Signed-off-by: Fiachra Corcoran <fiachra.corcoran@est.tech> * fix: CI drift — use slices.Contains, fix file permissions - Replace manual loops with slices.Contains for --help/--doc checks (matches what 'go fix' produces) - Change test file permissions from 0644 to 0600 (gosec G306) Signed-off-by: Fiachra Corcoran <fiachra.corcoran@est.tech> * fix: use <- All existing tests continue tomdtogo--> as end marker (matches catalog READMEs) The catalog functions use <- All existing tests continue tomdtogo--> (bare) as the section end marker, not <- All existing tests continue tomdtogo:End-->. Updated the parser and all tests to match the real-world README format. Signed-off-by: Fiachra Corcoran <fiachra.corcoran@est.tech> * fix: skip single-dash flags in file mode arg parsing Go test runner passes -test.* flags via os.Args. The file mode arg parser must skip all flag-like arguments (starting with -), not just those starting with --. Signed-off-by: Fiachra Corcoran <fiachra.corcoran@est.tech> * Address review comments Signed-off-by: Fiachra Corcoran <fiachra.corcoran@est.tech> --------- Signed-off-by: Fiachra Corcoran <fiachra.corcoran@est.tech>
1 parent aac5750 commit c3fd4dc

15 files changed

Lines changed: 2134 additions & 6 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,6 @@ ts/create-kpt-functions/bin
99
*.swo
1010
*.swp
1111

12+
# rapid property-based testing failure reproductions
13+
**/testdata/rapid/
14+

go/fn/go.mod

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ require (
1515
sigs.k8s.io/kustomize/kyaml v0.21.1
1616
)
1717

18-
require github.com/pkg/errors v0.9.1
18+
require (
19+
github.com/pkg/errors v0.9.1
20+
go.yaml.in/yaml/v3 v3.0.4
21+
pgregory.net/rapid v1.3.0
22+
)
1923

2024
require (
2125
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
@@ -39,7 +43,6 @@ require (
3943
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
4044
github.com/xlab/treeprint v1.2.0 // indirect
4145
go.yaml.in/yaml/v2 v2.4.4 // indirect
42-
go.yaml.in/yaml/v3 v3.0.4 // indirect
4346
golang.org/x/sys v0.44.0 // indirect
4447
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
4548
gopkg.in/yaml.v3 v3.0.1 // indirect

go/fn/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
7979
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
8080
k8s.io/kube-openapi v0.0.0-20260520065146-aa012df4f4af h1:zLXA2Irn14q2/06WMkxViyr7YCPUO2lJ0QYE9Juy5vA=
8181
k8s.io/kube-openapi v0.0.0-20260520065146-aa012df4f4af/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY=
82+
pgregory.net/rapid v1.3.0 h1:vBvO0VSqti75J1jjYqpgPNBLKMd1+gxa9fYo7vk/Exc=
83+
pgregory.net/rapid v1.3.0/go.mod h1:dPlE4OBBxgXPqkP79flB6sJL1dx5azpI7HQ9MY9Z7uk=
8284
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
8385
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
8486
sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI=

go/fn/internal/docs/markers.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright 2026 The kpt Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package docs
16+
17+
import "strings"
18+
19+
// Sections holds parsed README marker content.
20+
type Sections struct {
21+
Short string
22+
Long string
23+
Examples string
24+
}
25+
26+
const (
27+
markerShort = "<!--mdtogo:Short-->"
28+
markerLong = "<!--mdtogo:Long-->"
29+
markerExamples = "<!--mdtogo:Examples-->"
30+
markerEnd = "<!--mdtogo-->"
31+
)
32+
33+
// ParseMarkers extracts mdtogo marker sections from README content.
34+
// Missing markers result in empty strings for the corresponding sections.
35+
// When no markers are present at all, the full content (trimmed) is returned
36+
// as the Long description.
37+
func ParseMarkers(readme []byte) Sections {
38+
content := string(readme)
39+
40+
short := extractSection(content, markerShort)
41+
long := extractSection(content, markerLong)
42+
examples := extractSection(content, markerExamples)
43+
44+
// Fallback: if no markers are present at all, use full content as Long.
45+
if !hasAnyMarker(content) {
46+
return Sections{
47+
Long: strings.TrimSpace(content),
48+
}
49+
}
50+
51+
return Sections{
52+
Short: short,
53+
Long: long,
54+
Examples: examples,
55+
}
56+
}
57+
58+
// extractSection finds text between the given start marker and the next
59+
// <!--mdtogo--> end marker. Returns empty string if either marker is missing.
60+
func extractSection(content, startMarker string) string {
61+
startIdx := strings.Index(content, startMarker)
62+
if startIdx < 0 {
63+
return ""
64+
}
65+
afterStart := startIdx + len(startMarker)
66+
remaining := content[afterStart:]
67+
68+
before, _, ok := strings.Cut(remaining, markerEnd)
69+
if !ok {
70+
return ""
71+
}
72+
73+
return strings.TrimSpace(before)
74+
}
75+
76+
// hasAnyMarker reports whether the content contains any mdtogo marker.
77+
func hasAnyMarker(content string) bool {
78+
return strings.Contains(content, markerShort) ||
79+
strings.Contains(content, markerLong) ||
80+
strings.Contains(content, markerExamples) ||
81+
strings.Contains(content, markerEnd)
82+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Copyright 2026 The kpt Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package docs
16+
17+
import (
18+
"fmt"
19+
"strings"
20+
"testing"
21+
22+
"pgregory.net/rapid"
23+
)
24+
25+
// For any three strings (short, long, examples), formatting them into a README
26+
// with mdtogo markers and then parsing that README with ParseMarkers SHALL
27+
// produce a Sections struct with fields equal to the original strings (after trimming).
28+
29+
// genSectionContent generates arbitrary non-empty strings that do not contain
30+
// mdtogo markers (which would confuse the parser).
31+
func genSectionContent() *rapid.Generator[string] {
32+
return rapid.Custom(func(t *rapid.T) string {
33+
s := rapid.StringMatching(`[a-zA-Z0-9 \t\n.,;:!?(){}\[\]'"/_-]{1,200}`).Draw(t, "content")
34+
// Ensure the generated content does not accidentally contain marker strings.
35+
s = strings.ReplaceAll(s, "<!--mdtogo:", "")
36+
s = strings.ReplaceAll(s, "<!--", "")
37+
return s
38+
})
39+
}
40+
41+
// formatMarkedREADME formats three section strings into a README with mdtogo markers.
42+
func formatMarkedREADME(short, long, examples string) string {
43+
return fmt.Sprintf(`<!--mdtogo:Short-->
44+
%s
45+
<!--mdtogo-->
46+
47+
<!--mdtogo:Long-->
48+
%s
49+
<!--mdtogo-->
50+
51+
<!--mdtogo:Examples-->
52+
%s
53+
<!--mdtogo-->
54+
`, short, long, examples)
55+
}
56+
57+
// genNoMarkerContent generates arbitrary strings guaranteed not to contain
58+
// any mdtogo marker substrings.
59+
func genNoMarkerContent() *rapid.Generator[string] {
60+
return rapid.Custom(func(t *rapid.T) string {
61+
s := rapid.StringMatching(`[a-zA-Z0-9 \t\n.,;:!?(){}\[\]'"/_-]{0,300}`).Draw(t, "content")
62+
// Strip anything that could form a marker.
63+
s = strings.ReplaceAll(s, "<!--mdtogo:", "")
64+
s = strings.ReplaceAll(s, "<!--mdtogo", "")
65+
s = strings.ReplaceAll(s, "<!--", "")
66+
return s
67+
})
68+
}
69+
70+
func TestProperty7_MissingMarkersFallback(t *testing.T) {
71+
rapid.Check(t, func(t *rapid.T) {
72+
content := genNoMarkerContent().Draw(t, "readme")
73+
74+
sections := ParseMarkers([]byte(content))
75+
76+
if sections.Short != "" {
77+
t.Fatalf("Short should be empty for content without markers, got: %q", sections.Short)
78+
}
79+
if sections.Examples != "" {
80+
t.Fatalf("Examples should be empty for content without markers, got: %q", sections.Examples)
81+
}
82+
if got, want := sections.Long, strings.TrimSpace(content); got != want {
83+
t.Fatalf("Long mismatch:\n got: %q\n want: %q", got, want)
84+
}
85+
})
86+
}
87+
88+
func TestProperty1_MarkerParserRoundTrip(t *testing.T) {
89+
rapid.Check(t, func(t *rapid.T) {
90+
short := genSectionContent().Draw(t, "short")
91+
long := genSectionContent().Draw(t, "long")
92+
examples := genSectionContent().Draw(t, "examples")
93+
94+
readme := formatMarkedREADME(short, long, examples)
95+
sections := ParseMarkers([]byte(readme))
96+
97+
if got, want := sections.Short, strings.TrimSpace(short); got != want {
98+
t.Fatalf("Short mismatch:\n got: %q\n want: %q", got, want)
99+
}
100+
if got, want := sections.Long, strings.TrimSpace(long); got != want {
101+
t.Fatalf("Long mismatch:\n got: %q\n want: %q", got, want)
102+
}
103+
if got, want := sections.Examples, strings.TrimSpace(examples); got != want {
104+
t.Fatalf("Examples mismatch:\n got: %q\n want: %q", got, want)
105+
}
106+
})
107+
}

go/fn/internal/docs/metadata.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright 2026 The kpt Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package docs
16+
17+
import "go.yaml.in/yaml/v3"
18+
19+
// Metadata holds parsed metadata.yaml content.
20+
type Metadata struct {
21+
Image string `yaml:"image" json:"image"`
22+
Description string `yaml:"description" json:"description"`
23+
Tags []string `yaml:"tags" json:"tags"`
24+
SourceURL string `yaml:"sourceURL" json:"sourceURL"`
25+
ExamplePackageURLs []string `yaml:"examplePackageURLs" json:"examplePackageURLs"`
26+
License string `yaml:"license" json:"license"`
27+
Hidden bool `yaml:"hidden" json:"hidden"`
28+
}
29+
30+
// ParseMetadata parses metadata.yaml content into a Metadata struct.
31+
// Returns zero-value Metadata and an error if YAML is invalid.
32+
// Returns successfully with partial fields if optional fields are missing.
33+
func ParseMetadata(meta []byte) (Metadata, error) {
34+
var m Metadata
35+
if err := yaml.Unmarshal(meta, &m); err != nil {
36+
return Metadata{}, err
37+
}
38+
return m, nil
39+
}

0 commit comments

Comments
 (0)