Skip to content

Commit cc0d9c4

Browse files
jpbetzthockin
andauthored
Add named arg parser (#299)
* Add named arg parser Co-authored-by: Tim Hockin <thockin@google.com> * Support : in tag names, extend test coverage Co-authored-by: Tim Hockin <thockin@google.com> * simplify types * Drop trailing comment support, parse entire value as opaque text and return it * Check tag name is in tagNames before parsing * Decouple extract from parse * Move new extract and parse utilities to dedicated package * Retain original string of parsed ints. * Allow comment if there is not value * Simplify prefix handling, allow -._ in identifiers plus : in tag names * Simplify composition options * Generalize naming of typed value so it can be used for tag value parsing (not just arg value parsing) * Improve example for Extract godoc, fix bug found when adding tests that include comments * Simplify types * Support empty names following prefix for Extract, refine whitespace handling * Appy feedback --------- Co-authored-by: Tim Hockin <thockin@google.com>
1 parent 3d52566 commit cc0d9c4

File tree

7 files changed

+1021
-562
lines changed

7 files changed

+1021
-562
lines changed

v2/codetags/extractor.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package codetags
18+
19+
import (
20+
"strings"
21+
"unicode/utf8"
22+
)
23+
24+
// Extract identifies and collects lines containing special metadata tags.
25+
// It processes only lines that begin with the prefix.
26+
//
27+
// The portion of a line immediately following the prefix is treated as
28+
// a potential tag name. To be considered valid, this tag name must
29+
// match the regular expression `[a-zA-Z_][a-zA-Z0-9_.-:]*`.
30+
//
31+
// Extract returns a map where each key is a valid tag name found in
32+
// lines that begin with the prefix.
33+
// The value for each key is a slice of strings. Each string in this slice
34+
// represents the contents of an original line after the prefix has been removed.
35+
//
36+
// Example: When called with prefix "+k8s:", lines:
37+
//
38+
// Comment line without marker
39+
// +k8s:noArgs // comment
40+
// +withValue=value1
41+
// +withValue=value2
42+
// +k8s:withArg(arg1)=value1
43+
// +k8s:withArg(arg2)=value2 // comment
44+
// +k8s:withNamedArgs(arg1=value1, arg2=value2)=value
45+
//
46+
// Then this function will return:
47+
//
48+
// map[string][]string{
49+
// "noArgs": {"noArgs // comment"},
50+
// "withArg": {"withArg(arg1)=value1", "withArg(arg2)=value2 // comment"},
51+
// "withNamedArgs": {"withNamedArgs(arg1=value1, arg2=value2)=value"},
52+
// }
53+
func Extract(prefix string, lines []string) map[string][]string {
54+
out := map[string][]string{}
55+
for _, line := range lines {
56+
line = strings.TrimLeft(line, " \t")
57+
if !strings.HasPrefix(line, prefix) {
58+
continue
59+
}
60+
line = line[len(prefix):]
61+
62+
// Find the end of the presumed tag name.
63+
nameEnd := findNameEnd(line)
64+
name := line[:nameEnd]
65+
out[name] = append(out[name], line)
66+
}
67+
return out
68+
}
69+
70+
// findNameEnd matches a tag in the same way as the parser.
71+
func findNameEnd(s string) int {
72+
if len(s) == 0 {
73+
return 0
74+
}
75+
if r, _ := utf8.DecodeRuneInString(s); !isIdentBegin(r) {
76+
return 0
77+
}
78+
idx := strings.IndexFunc(s, func(r rune) bool {
79+
return !(isIdentInterior(r) || r == ':')
80+
})
81+
if idx == -1 {
82+
return len(s)
83+
}
84+
return idx
85+
}

v2/codetags/extractor_test.go

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package codetags
18+
19+
import (
20+
"reflect"
21+
"testing"
22+
23+
"github.com/google/go-cmp/cmp"
24+
)
25+
26+
func TestExtract(t *testing.T) {
27+
tests := []struct {
28+
name string
29+
prefix string
30+
lines []string
31+
want map[string][]string
32+
}{
33+
{
34+
name: "example",
35+
prefix: "+k8s:",
36+
lines: []string{
37+
"Comment line without marker",
38+
"+k8s:noArgs // comment",
39+
"+withValue=value1",
40+
"+withValue=value2",
41+
"+k8s:withArg(arg1)=value1",
42+
"+k8s:withArg(arg2)=value2 // comment",
43+
"+k8s:withNamedArgs(arg1=value1, arg2=value2)=value",
44+
},
45+
want: map[string][]string{
46+
"noArgs": {"noArgs // comment"},
47+
"withArg": {"withArg(arg1)=value1", "withArg(arg2)=value2 // comment"},
48+
"withNamedArgs": {"withNamedArgs(arg1=value1, arg2=value2)=value"},
49+
},
50+
},
51+
{
52+
name: "with args and values",
53+
prefix: "+",
54+
lines: []string{
55+
"+a:t1=tagValue",
56+
"+b:t1(arg)",
57+
"+b:t1(arg)=tagValue",
58+
"+a:t1(arg: value)",
59+
"+a:t1(arg: value)=tagValue",
60+
},
61+
want: map[string][]string{
62+
"a:t1": {"a:t1=tagValue", "a:t1(arg: value)", "a:t1(arg: value)=tagValue"},
63+
"b:t1": {"b:t1(arg)", "b:t1(arg)=tagValue"},
64+
},
65+
},
66+
{
67+
name: "empty name",
68+
prefix: "+k8s:",
69+
lines: []string{},
70+
want: map[string][]string{},
71+
},
72+
{
73+
name: "no matching lines",
74+
prefix: "+k8s:",
75+
lines: []string{
76+
"Comment line without marker",
77+
"Another comment line",
78+
},
79+
want: map[string][]string{},
80+
},
81+
{
82+
name: "different marker",
83+
prefix: "@k8s:",
84+
lines: []string{
85+
"Comment line without marker",
86+
"@k8s:required",
87+
"@validation:required",
88+
"+k8s:format=k8s-long-name",
89+
},
90+
want: map[string][]string{
91+
"required": {"required"},
92+
},
93+
},
94+
{
95+
name: "no group",
96+
prefix: "+",
97+
lines: []string{
98+
"+k8s:required",
99+
"+validation:required",
100+
"+validation:format=special",
101+
},
102+
want: map[string][]string{
103+
"k8s:required": {"k8s:required"},
104+
"validation:required": {"validation:required"},
105+
"validation:format": {"validation:format=special"},
106+
},
107+
},
108+
{
109+
name: "no name",
110+
prefix: "+",
111+
lines: []string{
112+
"+",
113+
"+ ",
114+
"+ // comment",
115+
},
116+
want: map[string][]string{
117+
"": {"", " ", " // comment"},
118+
},
119+
},
120+
{
121+
name: "whitespace",
122+
prefix: "+",
123+
lines: []string{
124+
" +name",
125+
" \t \t +name",
126+
" +name",
127+
" +name ",
128+
" +name ",
129+
" +name= value",
130+
" +name = value",
131+
" +name =value ",
132+
},
133+
want: map[string][]string{
134+
"name": {"name", "name", "name", "name ", "name ", "name= value", "name = value", "name =value "},
135+
},
136+
},
137+
}
138+
139+
for _, tt := range tests {
140+
t.Run(tt.name, func(t *testing.T) {
141+
got := Extract(tt.prefix, tt.lines)
142+
if !reflect.DeepEqual(got, tt.want) {
143+
t.Errorf("got:\n%#+v\nwant:\n%#+v\n", got, tt.want)
144+
}
145+
})
146+
}
147+
}
148+
149+
func TestExtractAndParse(t *testing.T) {
150+
mktags := func(t ...TypedTag) []TypedTag { return t }
151+
152+
cases := []struct {
153+
name string
154+
comments []string
155+
expect map[string][]TypedTag
156+
}{
157+
{
158+
name: "positional params",
159+
comments: []string{
160+
"+quoted(\"value\")",
161+
"+backticked(`value`)",
162+
"+ident(value)",
163+
"+integer(2)",
164+
"+negative(-5)",
165+
"+hex(0xFF00B3)",
166+
"+octal(0o04167)",
167+
"+binary(0b10101)",
168+
"+true(true)",
169+
"+false(false)",
170+
},
171+
expect: map[string][]TypedTag{
172+
"quoted": mktags(
173+
TypedTag{Name: "quoted", Args: []Arg{
174+
{Value: "value", Type: ArgTypeString},
175+
}},
176+
),
177+
"backticked": mktags(
178+
TypedTag{Name: "backticked", Args: []Arg{
179+
{Value: "value", Type: ArgTypeString},
180+
}},
181+
),
182+
"ident": mktags(
183+
TypedTag{Name: "ident", Args: []Arg{
184+
{Value: "value", Type: ArgTypeString},
185+
}},
186+
),
187+
"integer": mktags(
188+
TypedTag{Name: "integer", Args: []Arg{
189+
{Value: "2", Type: ArgTypeInt},
190+
}},
191+
),
192+
"negative": mktags(
193+
TypedTag{Name: "negative", Args: []Arg{
194+
{Value: "-5", Type: ArgTypeInt},
195+
}},
196+
),
197+
"hex": mktags(
198+
TypedTag{Name: "hex", Args: []Arg{
199+
{Value: "0xFF00B3", Type: ArgTypeInt},
200+
}},
201+
),
202+
"octal": mktags(
203+
TypedTag{Name: "octal", Args: []Arg{
204+
{Value: "0o04167", Type: ArgTypeInt},
205+
}},
206+
),
207+
"binary": mktags(
208+
TypedTag{Name: "binary", Args: []Arg{
209+
{Value: "0b10101", Type: ArgTypeInt},
210+
}},
211+
),
212+
"true": mktags(
213+
TypedTag{Name: "true", Args: []Arg{
214+
{Value: "true", Type: ArgTypeBool},
215+
}},
216+
),
217+
"false": mktags(
218+
TypedTag{Name: "false", Args: []Arg{
219+
{Value: "false", Type: ArgTypeBool},
220+
}},
221+
),
222+
},
223+
},
224+
{
225+
name: "named params",
226+
comments: []string{
227+
"+strings(q: \"value\", b: `value`, i: value)",
228+
"+numbers(n1: 2, n2: -5, n3: 0xFF00B3, n4: 0o04167, n5: 0b10101)",
229+
"+bools(t: true, f:false)",
230+
},
231+
expect: map[string][]TypedTag{
232+
"strings": mktags(
233+
TypedTag{Name: "strings", Args: []Arg{
234+
{Name: "q", Value: "value", Type: ArgTypeString},
235+
{Name: "b", Value: `value`, Type: ArgTypeString},
236+
{Name: "i", Value: "value", Type: ArgTypeString},
237+
}}),
238+
"numbers": mktags(
239+
TypedTag{Name: "numbers", Args: []Arg{
240+
{Name: "n1", Value: "2", Type: ArgTypeInt},
241+
{Name: "n2", Value: "-5", Type: ArgTypeInt},
242+
{Name: "n3", Value: "0xFF00B3", Type: ArgTypeInt},
243+
{Name: "n4", Value: "0o04167", Type: ArgTypeInt},
244+
{Name: "n5", Value: "0b10101", Type: ArgTypeInt},
245+
}}),
246+
"bools": mktags(
247+
TypedTag{Name: "bools", Args: []Arg{
248+
{Name: "t", Value: "true", Type: ArgTypeBool},
249+
{Name: "f", Value: "false", Type: ArgTypeBool},
250+
}}),
251+
},
252+
},
253+
}
254+
255+
for _, tc := range cases {
256+
t.Run(tc.name, func(t *testing.T) {
257+
out := map[string][]TypedTag{}
258+
for name, matchedTags := range Extract("+", tc.comments) {
259+
parsed, err := ParseAll(matchedTags)
260+
if err != nil {
261+
t.Fatalf("case %q: unexpected error: %v", tc.name, err)
262+
}
263+
out[name] = parsed
264+
}
265+
if !reflect.DeepEqual(out, tc.expect) {
266+
t.Errorf("case %q: wrong result:\n%v", tc.name, cmp.Diff(tc.expect, out))
267+
}
268+
})
269+
}
270+
}

0 commit comments

Comments
 (0)