diff --git a/internal/workload/v1/kinds/workload.go b/internal/workload/v1/kinds/workload.go index 5875386..6911d59 100644 --- a/internal/workload/v1/kinds/workload.go +++ b/internal/workload/v1/kinds/workload.go @@ -365,12 +365,7 @@ func (ws *WorkloadSpec) processMarkerResults(markerResults []*inspect.YAMLResult continue } - // set the comments based on the description field of a field marker - comments := []string{} - - if marker.GetDescription() != "" { - comments = append(comments, strings.Split(marker.GetDescription(), "\n")...) - } + comments := marker.GetComments("+kubebuilder:") // set the sample value based on if a default was specified in the marker or not if marker.GetDefault() != nil { diff --git a/internal/workload/v1/markers/collection_field_marker.go b/internal/workload/v1/markers/collection_field_marker.go index 74012a6..02b1903 100644 --- a/internal/workload/v1/markers/collection_field_marker.go +++ b/internal/workload/v1/markers/collection_field_marker.go @@ -137,3 +137,7 @@ func (cfm *CollectionFieldMarker) SetDescription(description string) { func (cfm *CollectionFieldMarker) SetForCollection(forCollection bool) { cfm.forCollection = forCollection } + +func (cfm *CollectionFieldMarker) GetComments(exceptions ...string) []string { + return commentsFromMarker(cfm.GetDescription(), exceptions...) +} diff --git a/internal/workload/v1/markers/field_marker.go b/internal/workload/v1/markers/field_marker.go index 11ebfca..701a2b2 100644 --- a/internal/workload/v1/markers/field_marker.go +++ b/internal/workload/v1/markers/field_marker.go @@ -162,3 +162,7 @@ func (fm *FieldMarker) SetDescription(description string) { func (fm *FieldMarker) SetForCollection(forCollection bool) { fm.forCollection = forCollection } + +func (fm *FieldMarker) GetComments(exceptions ...string) []string { + return commentsFromMarker(fm.GetDescription(), exceptions...) +} diff --git a/internal/workload/v1/markers/markers.go b/internal/workload/v1/markers/markers.go index d32a2ff..1187e19 100644 --- a/internal/workload/v1/markers/markers.go +++ b/internal/workload/v1/markers/markers.go @@ -45,6 +45,7 @@ type FieldMarkerProcessor interface { GetReplaceText() string GetSpecPrefix() string GetSourceCodeVariable() string + GetComments(exceptions ...string) []string IsCollectionFieldMarker() bool IsFieldMarker() bool @@ -219,6 +220,169 @@ func validField(field string, validFields []string) bool { return false } +const commentWrapWidth = 80 + +// commentsFromMarker builds the formatted comment slice for a field marker. +// It splits the description into word-wrapped blocks (at commentWrapWidth) and +// prepends a blank line so the description is visually separated from any +// preceding marker annotations (e.g. the "(Default: X)" that setDefault writes +// into api.Markers). Any block line that begins with an exception prefix is +// emitted verbatim without word-wrapping. +// +// Note: the default annotation itself is intentionally NOT included here — it +// is already written to api.Markers by setDefault/setCommentsAndDefault. +func commentsFromMarker(description string, exceptions ...string) []string { + description = strings.Trim(description, "\n") + if description == "" { + return nil + } + + // Leading blank line separates the description from marker annotations + // (e.g. +kubebuilder:default=X or (Default: X)) that precede it. + comments := []string{""} + + for i, block := range buildDescriptionBlocks(strings.Split(description, "\n")) { + blockLines := wrapCommentBlock(block, exceptions) + if len(blockLines) == 0 { + continue + } + + if i > 0 { + comments = append(comments, "") + } + + comments = append(comments, blockLines...) + } + + return normalizeCommentLines(comments) +} + +// buildDescriptionBlocks splits trimmed non-blank lines into paragraph blocks, +// separated by blank (or whitespace-only) lines. +func buildDescriptionBlocks(rawLines []string) [][]string { + var blocks [][]string + + var current []string + + for _, line := range rawLines { + if trimmed := strings.TrimSpace(line); trimmed == "" { + if len(current) > 0 { + blocks = append(blocks, current) + current = nil + } + } else { + current = append(current, trimmed) + } + } + + if len(current) > 0 { + blocks = append(blocks, current) + } + + return blocks +} + +// normalizeCommentLines collapses consecutive blank entries into one and removes +// any trailing blank entry. Returns nil when nothing remains. +func normalizeCommentLines(comments []string) []string { + normalized := comments[:0:0] + + for i, c := range comments { + if c == "" && i > 0 && comments[i-1] == "" { + continue + } + + normalized = append(normalized, c) + } + + for len(normalized) > 0 && normalized[len(normalized)-1] == "" { + normalized = normalized[:len(normalized)-1] + } + + if len(normalized) == 0 { + return nil + } + + return normalized +} + +// wrapCommentBlock emits the lines of a description block as wrapped comment strings. +// Lines beginning with an exception prefix are passed through verbatim on their +// own line; all other lines are joined and word-wrapped at commentWrapWidth. +func wrapCommentBlock(lines, exceptions []string) []string { + var result []string + + var pending []string + + flush := func() { + if len(pending) == 0 { + return + } + + result = append(result, wrapCommentLine(strings.Join(pending, " "))...) + pending = nil + } + + for _, line := range lines { + if isCommentLineException(line, exceptions) { + flush() + result = append(result, line) + } else { + pending = append(pending, line) + } + } + + flush() + + return result +} + +var wordRe = regexp.MustCompile(`\S+`) + +// wrapCommentLine wraps text at commentWrapWidth characters, breaking only at word +// boundaries. Whitespace runs between words (e.g. double-spaces after a +// sentence-ending period) are preserved in the output. +func wrapCommentLine(text string) []string { + if text == "" { + return nil + } + locs := wordRe.FindAllStringIndex(text, -1) + + if len(locs) == 0 { + return nil + } + + var lines []string + + lineStart := locs[0][0] + lineEnd := locs[0][1] + + for _, loc := range locs[1:] { + wordEnd := loc[1] + if wordEnd-lineStart > commentWrapWidth { + lines = append(lines, text[lineStart:lineEnd]) + lineStart = loc[0] + } + + lineEnd = wordEnd + } + + lines = append(lines, text[lineStart:lineEnd]) + + return lines +} + +// isCommentLineException returns true when line begins with any of the exception prefixes. +func isCommentLineException(line string, exceptions []string) bool { + for _, exc := range exceptions { + if strings.HasPrefix(line, exc) { + return true + } + } + + return false +} + // getSourceCodeFieldVariable gets a full variable name for a marker as it is intended to be // passed into the generate package to generate the source code. This includes particular // tags that are needed by the generator to properly identify when a variable starts and ends. diff --git a/internal/workload/v1/markers/markers_internal_test.go b/internal/workload/v1/markers/markers_internal_test.go index 4cb9536..25c9c36 100644 --- a/internal/workload/v1/markers/markers_internal_test.go +++ b/internal/workload/v1/markers/markers_internal_test.go @@ -844,3 +844,348 @@ func Test_transformYAML(t *testing.T) { }) } } + +func Test_isCommentLineException(t *testing.T) { + t.Parallel() + + type args struct { + line string + exceptions []string + } + + tests := []struct { + name string + args args + want bool + }{ + { + name: "ensure line matching exception prefix returns true", + args: args{ + line: "+kubebuilder:validation:Optional", + exceptions: []string{"+kubebuilder:"}, + }, + want: true, + }, + { + name: "ensure line not matching any exception returns false", + args: args{ + line: "this is a normal description line", + exceptions: []string{"+kubebuilder:"}, + }, + want: false, + }, + { + name: "ensure empty exceptions list always returns false", + args: args{ + line: "+kubebuilder:validation:Optional", + exceptions: []string{}, + }, + want: false, + }, + { + name: "ensure empty line with no exceptions returns false", + args: args{ + line: "", + exceptions: []string{"+kubebuilder:"}, + }, + want: false, + }, + { + name: "ensure line matching one of multiple exceptions returns true", + args: args{ + line: "+operator-builder:field", + exceptions: []string{"+kubebuilder:", "+operator-builder:"}, + }, + want: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := isCommentLineException(tt.args.line, tt.args.exceptions); got != tt.want { + t.Errorf("isCommentLineException() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_wrapCommentLine(t *testing.T) { + t.Parallel() + + type args struct { + text string + } + + tests := []struct { + name string + args args + want []string + }{ + { + name: "ensure empty string returns nil", + args: args{text: ""}, + want: nil, + }, + { + name: "ensure whitespace-only string returns nil", + args: args{text: " "}, + want: nil, + }, + { + name: "ensure short line is returned as a single element", + args: args{text: "short description"}, + want: []string{"short description"}, + }, + { + name: "ensure line exactly at wrap width is not split", + args: args{ + // 80 characters exactly + text: "aaaaaaaaaa bbbbbbbbbb cccccccccc dddddddddd eeeeeeeeee ffffffffff gggggggggg h", + }, + want: []string{"aaaaaaaaaa bbbbbbbbbb cccccccccc dddddddddd eeeeeeeeee ffffffffff gggggggggg h"}, + }, + { + name: "ensure line exceeding wrap width is split at word boundary", + args: args{ + text: "This is a description that is long enough to exceed the eighty character wrap width limit.", + }, + want: []string{ + "This is a description that is long enough to exceed the eighty character wrap", + "width limit.", + }, + }, + { + name: "ensure single word longer than wrap width is returned on one line", + args: args{ + text: "us-central1-docker.pkg.dev/wanaware-core-dev/function-integrator/function-integrator:latest", + }, + want: []string{ + "us-central1-docker.pkg.dev/wanaware-core-dev/function-integrator/function-integrator:latest", + }, + }, + { + name: "ensure double-space between words is preserved in output", + args: args{ + text: "End of sentence. Start of next sentence.", + }, + want: []string{"End of sentence. Start of next sentence."}, + }, + { + name: "ensure long text is split into multiple lines", + args: args{ + // "fourteen" ends at exactly column 80, so it stays on the first line; + // the split happens before "fifteen". + text: "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen", + }, + want: []string{ + "one two three four five six seven eight nine ten eleven twelve thirteen fourteen", + "fifteen sixteen", + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := wrapCommentLine(tt.args.text) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("wrapCommentLine() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_wrapCommentBlock(t *testing.T) { + t.Parallel() + + type args struct { + lines []string + exceptions []string + } + + tests := []struct { + name string + args args + want []string + }{ + { + name: "ensure empty block returns nil", + args: args{ + lines: []string{}, + exceptions: []string{"+kubebuilder:"}, + }, + want: nil, + }, + { + name: "ensure block with single short line is returned as-is", + args: args{ + lines: []string{"a short description"}, + exceptions: []string{"+kubebuilder:"}, + }, + want: []string{"a short description"}, + }, + { + name: "ensure multiple lines are joined and word-wrapped together", + args: args{ + lines: []string{"first line", "second line", "third line"}, + exceptions: []string{"+kubebuilder:"}, + }, + want: []string{"first line second line third line"}, + }, + { + name: "ensure exception line is emitted verbatim without joining", + args: args{ + lines: []string{"+kubebuilder:validation:Optional"}, + exceptions: []string{"+kubebuilder:"}, + }, + want: []string{"+kubebuilder:validation:Optional"}, + }, + { + name: "ensure exception lines are flushed between non-exception lines", + args: args{ + lines: []string{"before exception", "+kubebuilder:validation:Optional", "after exception"}, + exceptions: []string{"+kubebuilder:"}, + }, + want: []string{"before exception", "+kubebuilder:validation:Optional", "after exception"}, + }, + { + name: "ensure all-exception block emits each line verbatim", + args: args{ + lines: []string{"+kubebuilder:default=true", "+kubebuilder:validation:Optional"}, + exceptions: []string{"+kubebuilder:"}, + }, + want: []string{"+kubebuilder:default=true", "+kubebuilder:validation:Optional"}, + }, + { + name: "ensure long joined line is word-wrapped at wrap width", + args: args{ + lines: []string{ + "This is the first part of a description", + "that when joined together exceeds the eighty character wrap width boundary.", + }, + exceptions: []string{"+kubebuilder:"}, + }, + want: []string{ + "This is the first part of a description that when joined together exceeds the", + "eighty character wrap width boundary.", + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := wrapCommentBlock(tt.args.lines, tt.args.exceptions) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("wrapCommentBlock() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_commentsFromMarker(t *testing.T) { + t.Parallel() + + type args struct { + description string + exceptions []string + } + + tests := []struct { + name string + args args + want []string + }{ + { + name: "ensure empty description returns nil", + args: args{ + description: "", + exceptions: []string{"+kubebuilder:"}, + }, + want: nil, + }, + { + name: "ensure newline-only description returns nil", + args: args{ + description: "\n\n\n", + exceptions: []string{"+kubebuilder:"}, + }, + want: nil, + }, + { + name: "ensure single-paragraph description has leading blank separator", + args: args{ + description: "\n a short description", + exceptions: []string{"+kubebuilder:"}, + }, + want: []string{"", "a short description"}, + }, + { + name: "ensure two paragraphs are separated by exactly one blank line", + args: args{ + description: "\n first paragraph\n\n second paragraph", + exceptions: []string{"+kubebuilder:"}, + }, + want: []string{"", "first paragraph", "", "second paragraph"}, + }, + { + name: "ensure double blank line between paragraphs is normalized to one blank", + args: args{ + description: "\n first paragraph\n\n\n second paragraph", + exceptions: []string{"+kubebuilder:"}, + }, + want: []string{"", "first paragraph", "", "second paragraph"}, + }, + { + name: "ensure description with leading and trailing newlines is trimmed", + args: args{ + description: "\n\n description text \n\n", + exceptions: []string{"+kubebuilder:"}, + }, + want: []string{"", "description text"}, + }, + { + name: "ensure exception lines in description are passed through verbatim", + args: args{ + description: "\n description text\n +kubebuilder:validation:Optional", + exceptions: []string{"+kubebuilder:"}, + }, + want: []string{"", "description text", "+kubebuilder:validation:Optional"}, + }, + { + name: "ensure three-paragraph description produces correct structure", + args: args{ + description: "\n first paragraph\n\n second paragraph\n\n third paragraph", + exceptions: []string{"+kubebuilder:"}, + }, + want: []string{"", "first paragraph", "", "second paragraph", "", "third paragraph"}, + }, + { + name: "ensure long description line is word-wrapped", + args: args{ + description: "\n This is a description that is long enough to exceed the eighty character wrap width limit set by commentWrapWidth.", + exceptions: []string{"+kubebuilder:"}, + }, + want: []string{ + "", + "This is a description that is long enough to exceed the eighty character wrap", + "width limit set by commentWrapWidth.", + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := commentsFromMarker(tt.args.description, tt.args.exceptions...) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("commentsFromMarker() = %v, want %v", got, tt.want) + } + }) + } +}