diff --git a/fragmentation/fragment.go b/fragmentation/fragment.go index d3581c8b..8fb8e3c1 100644 --- a/fragmentation/fragment.go +++ b/fragmentation/fragment.go @@ -44,6 +44,15 @@ type Fragment struct { Partitions []Partition } +// partitionText contains selected source lines and their indentation group. +type partitionText struct { + // lines contains the source lines selected by one partition. + lines []string + + // indentGroup identifies partitions whose common indentation is normalized together. + indentGroup string +} + // CreateDefaultFragment creates a whole-file fragment. // // Returns whole-file fragment. @@ -76,15 +85,12 @@ func (f Fragment) text(lines []string, separator string) (string, error) { if err != nil { return "", err } - var fragmentText []string - for _, partition := range partitionsTexts { - fragmentText = append(fragmentText, partition...) - } - indentation := indent.MaxCommonIndentation(fragmentText) + indentations := commonIndentations(partitionsTexts) text := "" - for index, partitionText := range partitionsTexts { - cutIndentLines := indent.CutIndent(partitionText, indentation) + for index, partition := range partitionsTexts { + indentation := indentations[partition.indentGroup] + cutIndentLines := indent.CutIndent(partition.lines, indentation) if index > 0 { separatorIndentation := separatorIndent(cutIndentLines) @@ -103,19 +109,45 @@ func (f Fragment) text(lines []string, separator string) (string, error) { // lines - provides every source line in the file. // // Returns: -// [][]string - selected lines grouped by partition. +// []partitionText - selected lines and indentation group for every partition. // error - when a partition cannot select its lines. -func (f Fragment) obtainPartitionTexts(lines []string) ([][]string, error) { - var partitionLines [][]string +func (f Fragment) obtainPartitionTexts(lines []string) ([]partitionText, error) { + var partitions []partitionText for _, part := range f.Partitions { - partitionText, err := part.Select(lines) + selectedLines, err := part.Select(lines) if err != nil { return nil, err } - partitionLines = append(partitionLines, partitionText) + partitions = append(partitions, partitionText{ + lines: selectedLines, + indentGroup: part.IndentGroup, + }) + } + + return partitions, nil +} + +// commonIndentations calculates common indentation for every partition group. +// +// Parameters: +// partitions - provides selected source lines in source order. +// +// Returns indentation width by group name. +func commonIndentations(partitions []partitionText) map[string]int { + groupLines := make(map[string][]string) + for _, partition := range partitions { + groupLines[partition.indentGroup] = append( + groupLines[partition.indentGroup], + partition.lines..., + ) + } + + indentations := make(map[string]int, len(groupLines)) + for indentGroup, lines := range groupLines { + indentations[indentGroup] = indent.MaxCommonIndentation(lines) } - return partitionLines, nil + return indentations } // separatorIndent returns the indentation to use before a partition separator. diff --git a/fragmentation/fragment_builder.go b/fragmentation/fragment_builder.go index e6cffa4c..bde77d00 100644 --- a/fragmentation/fragment_builder.go +++ b/fragmentation/fragment_builder.go @@ -50,6 +50,17 @@ type FragmentBuilder struct { // // Returns an error when the previous partition is still open. func (b *FragmentBuilder) AddStartPosition(startPosition int) error { + return b.addStartPosition(startPosition, "") +} + +// addStartPosition adds a partition with its indentation group. +// +// Parameters: +// startPosition - provides the zero-based source line where the partition starts. +// indentGroup - identifies partitions whose common indentation is normalized together. +// +// Returns an error when the previous partition is still open. +func (b *FragmentBuilder) addStartPosition(startPosition int, indentGroup string) error { if !b.isPartitionsEmpty() { lastPartition := b.lastAddedPartition() if lastPartition.EndPosition < 0 { @@ -60,6 +71,7 @@ func (b *FragmentBuilder) AddStartPosition(startPosition int) error { partition := NewPartition() partition.StartPosition = startPosition + partition.IndentGroup = indentGroup b.Partitions = append(b.Partitions, partition) return nil diff --git a/fragmentation/fragmentation.go b/fragmentation/fragmentation.go index 07c327c2..65471a2a 100644 --- a/fragmentation/fragmentation.go +++ b/fragmentation/fragmentation.go @@ -149,7 +149,7 @@ func (f Fragmentation) DoFragmentation() ([]string, map[string]Fragment, error) func (f Fragmentation) parseLine(line string, contentToRender []string) ([]string, error) { cursor := len(contentToRender) - docFragments, startErr := FindDocFragments(line) + docFragment, startErr := findDocFragmentDeclaration(line) if startErr != nil { return nil, startErr } @@ -159,8 +159,8 @@ func (f Fragmentation) parseLine(line string, contentToRender []string) ([]strin } switch { - case len(docFragments) > 0: - if err := f.parseStartDocFragments(docFragments, cursor); err != nil { + case len(docFragment.names) > 0: + if err := f.parseStartDocFragments(docFragment, cursor); err != nil { return nil, err } case len(endDocFragments) > 0: @@ -177,8 +177,11 @@ func (f Fragmentation) parseLine(line string, contentToRender []string) ([]strin // parseStartDocFragments starts a new partition for each named fragment marker. // // It creates fragment builders when necessary. -func (f Fragmentation) parseStartDocFragments(docFragments []string, cursor int) error { - for _, fragmentName := range docFragments { +func (f Fragmentation) parseStartDocFragments( + declaration fragmentDeclaration, + cursor int, +) error { + for _, fragmentName := range declaration.names { fragment, exists := f.fragmentBuilders[fragmentName] if !exists { builder := FragmentBuilder{ @@ -188,7 +191,7 @@ func (f Fragmentation) parseStartDocFragments(docFragments []string, cursor int) f.fragmentBuilders[fragmentName] = &builder fragment = f.fragmentBuilders[fragmentName] } - if err := fragment.AddStartPosition(cursor); err != nil { + if err := fragment.addStartPosition(cursor, declaration.indentGroup); err != nil { return err } } diff --git a/fragmentation/fragmentation_test.go b/fragmentation/fragmentation_test.go index 20113ab5..d452ae15 100644 --- a/fragmentation/fragmentation_test.go +++ b/fragmentation/fragmentation_test.go @@ -49,6 +49,7 @@ const ( twoFragmentsFileName = "TwoFragments.java" overlappingFragmentsFileName = "OverlappingFragments.java" emptyLaterPartitionsFileName = "EmptyLaterPartitions.java" + groupedIndentFileName = "GroupedIndent.java" emptyFileName = "Empty.java" indent = " " ) @@ -430,7 +431,11 @@ var _ = Describe("Fragmentation", func() { It("should report malformed fragment markers with source line context", func() { sourceRoot := GinkgoT().TempDir() sourcePath := filepath.Join(sourceRoot, "Malformed.java") - Expect(os.WriteFile(sourcePath, []byte("// #docfragment"), 0600)).To(Succeed()) + Expect(os.WriteFile( + sourcePath, + []byte(`// #docfragment "main" indentgroup="imports"`), + 0600, + )).To(Succeed()) frag, err := fragmentation.NewFragmentation(sourcePath) Expect(err).ShouldNot(HaveOccurred()) @@ -442,7 +447,7 @@ var _ = Describe("Fragmentation", func() { ContainSubstring("failed to do fragmentation"), ContainSubstring("file://"), ContainSubstring("Malformed.java:1"), - ContainSubstring("without any name"), + ContainSubstring("unexpected attribute after `#docfragment` declaration"), ))) }) @@ -619,6 +624,45 @@ line Expect(openings[1]).Should(Equal(subMainFragment)) }) + It("should find fragment openings with an indentation group", func() { + docFragment := fmt.Sprintf( + "// #docfragment \"%s\",\"%s\" indent-group=\"imports\"", + mainFragment, + subMainFragment, + ) + + openings, err := fragmentation.FindDocFragments(docFragment) + + Expect(err).ShouldNot(HaveOccurred()) + Expect(openings).Should(Equal([]string{mainFragment, subMainFragment})) + }) + + It("should allow non-attribute text after fragment declarations", func() { + declarations := []string{ + ``, + `{% #docfragment "main" #}`, + `{{!-- #docfragment "main" --}}`, + `<% #docfragment "main" %>`, + `(* #docfragment "main" *)`, + `<# #docfragment "main" #>`, + ``, + `/* #docfragment "main" */ public void run() {`, + `// #docfragment "main" (see the loop below)`, + } + for _, declaration := range declarations { + openings, err := fragmentation.FindDocFragments(declaration) + + Expect(err).ShouldNot(HaveOccurred(), declaration) + Expect(openings).Should(Equal([]string{mainFragment}), declaration) + } + + endings, err := fragmentation.FindEndDocFragments( + `/* #enddocfragment "main" */ public void run() {`, + ) + Expect(err).ShouldNot(HaveOccurred()) + Expect(endings).Should(Equal([]string{mainFragment})) + }) + It("should correctly find fragment endings", func() { endDocFragment := fmt.Sprintf( "// #enddocfragment \"%s\",\"%s\"", mainFragment, subMainFragment) @@ -663,6 +707,122 @@ line ContainSubstring("invalid syntax"), ))) }) + + It("should report an empty fragment name", func() { + openings, err := fragmentation.FindDocFragments(`// #docfragment ""`) + + Expect(openings).Should(BeEmpty()) + Expect(err).Should(MatchError("fragment name must not be empty")) + }) + + It("should report an unquoted fragment name before trailing text", func() { + openings, err := fragmentation.FindDocFragments("// #docfragment main trailing") + + Expect(openings).Should(BeEmpty()) + Expect(err).Should(MatchError(And( + ContainSubstring("failed to unquote name `main`"), + ContainSubstring("invalid syntax"), + ))) + }) + + It("should allow escaped quotes in fragment names", func() { + openings, err := fragmentation.FindDocFragments( + `// #docfragment "main\"part"`, + ) + + Expect(err).ShouldNot(HaveOccurred()) + Expect(openings).Should(Equal([]string{`main"part`})) + }) + + It("should report an indentation group without an equals sign", func() { + openings, err := fragmentation.FindDocFragments( + `// #docfragment "main" indent-group "imports"`, + ) + + Expect(openings).Should(BeEmpty()) + Expect(err).Should(MatchError( + `indent-group must use the form indent-group="name"`, + )) + }) + + It("should report an unquoted indentation group", func() { + openings, err := fragmentation.FindDocFragments( + "// #docfragment \"main\" indent-group=imports", + ) + + Expect(openings).Should(BeEmpty()) + Expect(err).Should(MatchError(ContainSubstring( + "indent-group value `imports` must be quoted", + ))) + }) + + It("should report an empty indentation group", func() { + openings, err := fragmentation.FindDocFragments( + "// #docfragment \"main\" indent-group=\"\"", + ) + + Expect(openings).Should(BeEmpty()) + Expect(err).Should(MatchError("indent-group must not be empty")) + }) + + It("should report an unterminated indentation group", func() { + openings, err := fragmentation.FindDocFragments( + `// #docfragment "main" indent-group="imports`, + ) + + Expect(openings).Should(BeEmpty()) + Expect(err).Should(MatchError(And( + ContainSubstring(`failed to unquote indent-group `), + ContainSubstring("invalid syntax"), + ))) + }) + + It("should reject an indentation group on an end marker", func() { + endings, err := fragmentation.FindEndDocFragments( + "// #enddocfragment \"main\" indent-group=\"imports\"", + ) + + Expect(endings).Should(BeEmpty()) + Expect(err).Should(MatchError(ContainSubstring( + "indent-group is only supported by #docfragment", + ))) + }) + + It("should reject unrecognized attributes after a fragment declaration", func() { + invalidSuffixes := []string{ + `indentgroup="imports"`, + `indent_group="imports"`, + `INDENT-GROUP="imports"`, + `indent-group="a" indent-group="b"`, + } + for _, suffix := range invalidSuffixes { + openings, err := fragmentation.FindDocFragments( + `// #docfragment "main" ` + suffix, + ) + + Expect(openings).Should(BeEmpty()) + Expect(err).Should(MatchError(ContainSubstring( + "unexpected attribute after `#docfragment` declaration", + )), suffix) + } + }) + }) + + It("should normalize common indentation within each indentation group", func() { + content := resolveTestFragment(resolver, groupedIndentFileName, "Example", config) + + Expect(content).Should(Equal([]string{ + "import java.util.List;", + indent + config.Separator, + indent + `static final String LABEL = "value";`, + config.Separator, + "var first = values.get(0);", + indent + "var nested = first.trim();", + config.Separator, + "var second = values.get(1);", + config.Separator, + "System.out.println(nested + second);", + })) }) It("should render empty later partitions with an unindented separator", func() { diff --git a/fragmentation/lookup.go b/fragmentation/lookup.go index ae8b7616..d94950c2 100644 --- a/fragmentation/lookup.go +++ b/fragmentation/lookup.go @@ -33,8 +33,6 @@ import ( "strings" ) -var quotedNamePattern = regexp.MustCompile("\"(.*)\"") - const ( // FragmentStart marks the beginning of a named source fragment. FragmentStart = "#docfragment" @@ -43,6 +41,18 @@ const ( FragmentEnd = "#enddocfragment" ) +// attributePrefixPattern matches an attribute-shaped token at the start of marker text. +var attributePrefixPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z_-]*[ \t]*=`) + +// fragmentDeclaration describes one source fragment opening marker. +type fragmentDeclaration struct { + // names contains the fragments opened by the marker. + names []string + + // indentGroup identifies partitions that share common indentation. + indentGroup string +} + // FindDocFragments finds fragment names declared with the start marker. // // For example, FindDocFragments("// #docfragment \"main\",\"sub-main\"\n") @@ -55,7 +65,12 @@ const ( // []string - fragment names declared on the line. // error - when a declaration is malformed. func FindDocFragments(line string) ([]string, error) { - return lookup(line, FragmentStart) + declaration, err := findDocFragmentDeclaration(line) + if err != nil { + return nil, err + } + + return declaration.names, nil } // FindEndDocFragments finds fragment names declared with the end marker. @@ -70,42 +85,206 @@ func FindDocFragments(line string) ([]string, error) { // []string - fragment names closed on the line. // error - when a declaration is malformed. func FindEndDocFragments(line string) ([]string, error) { - return lookup(line, FragmentEnd) + declaration, err := parseFragmentDeclaration(line, FragmentEnd, false) + if err != nil { + return nil, err + } + + return declaration.names, nil } -// lookup finds fragment names in line after the given fragment marker prefix. +// findDocFragmentDeclaration finds fragment names and indentation metadata on an opening marker. // -// For example, lookup("// #enddocfragment \"main\",\"sub-main\"\n", "#enddocfragment") -// returns ["main", "sub-main"] +// Parameters: +// line - provides one source line. +// +// Returns: +// fragmentDeclaration - parsed opening marker, or an empty declaration when no marker exists. +// error - when the declaration is malformed. +func findDocFragmentDeclaration(line string) (fragmentDeclaration, error) { + return parseFragmentDeclaration(line, FragmentStart, true) +} + +// parseFragmentDeclaration parses names and optional indentation metadata after a marker. +// +// Attribute-shaped text after the declaration is rejected, while other text is ignored +// for compatibility with source-language comment syntax and inline marker annotations. // // Parameters: // line - provides one source line to search in. -// prefix - provides the fragment marker prefix, for example "#docfragment". +// prefix - provides the fragment marker prefix. +// allowIndentGroup - allows an indent-group attribute on the marker. // // Returns: -// []string - fragment names found on the line. -// error - when prefix is found without valid names. -func lookup(line string, prefix string) ([]string, error) { - var unquotedNames []string - if strings.Contains(line, prefix) { - // 1 for trailing space after the prefix. - fragmentsStart := strings.Index(line, prefix) + len(prefix) + 1 - if len(line) < fragmentsStart { - return unquotedNames, fmt.Errorf( - "found `%s` prefix without any name", prefix, +// fragmentDeclaration - parsed marker, or an empty declaration when no marker exists. +// error - when the declaration is malformed. +func parseFragmentDeclaration( + line string, + prefix string, + allowIndentGroup bool, +) (fragmentDeclaration, error) { + var declaration fragmentDeclaration + markerPosition := strings.Index(line, prefix) + if markerPosition < 0 { + return declaration, nil + } + + remainder := strings.TrimLeft(line[markerPosition+len(prefix):], "\t ") + if remainder == "" { + return declaration, fmt.Errorf("found `%s` prefix without any name", prefix) + } + + for { + name, rest, err := consumeQuotedName(remainder) + if err != nil { + return fragmentDeclaration{}, err + } + declaration.names = append(declaration.names, name) + remainder = strings.TrimLeft(rest, "\t ") + if !strings.HasPrefix(remainder, ",") { + break + } + remainder = strings.TrimLeft(strings.TrimPrefix(remainder, ","), "\t ") + } + + remainder = strings.TrimLeft(remainder, "\t ") + if strings.HasPrefix(remainder, "indent-group") { + if !allowIndentGroup { + return fragmentDeclaration{}, fmt.Errorf( + "indent-group is only supported by %s", FragmentStart, ) } - for _, fragmentName := range strings.Split(line[fragmentsStart:], ",") { - quotedName := strings.Trim(fragmentName, "\n\t ") - unquotedName, err := unquoteName(quotedName) - if err != nil { - return unquotedNames, err - } - unquotedNames = append(unquotedNames, unquotedName) + indentGroup, rest, err := parseIndentGroup(remainder) + if err != nil { + return fragmentDeclaration{}, err } + declaration.indentGroup = indentGroup + remainder = rest + } + if err := validateDeclarationRemainder(remainder, prefix); err != nil { + return fragmentDeclaration{}, err + } + + return declaration, nil +} + +// consumeQuotedName parses the next quoted fragment name. +// +// Parameters: +// source - provides marker text beginning with a fragment name. +// +// Returns: +// string - unquoted fragment name. +// string - unconsumed marker text. +// error - when the name is not a valid quoted string. +func consumeQuotedName(source string) (string, string, error) { + quotedName, remainder := consumeQuotedValue(source) + name, err := unquoteName(quotedName) + if err != nil { + return "", "", err } - return unquotedNames, nil + return name, remainder, nil +} + +// parseIndentGroup parses the optional indent-group marker attribute. +// +// Parameters: +// source - provides marker text beginning with indent-group. +// +// Returns: +// string - unquoted indentation group name. +// string - unconsumed marker text. +// error - when the attribute is malformed or empty. +func parseIndentGroup(source string) (string, string, error) { + remainder := strings.TrimLeft(strings.TrimPrefix(source, "indent-group"), "\t ") + if !strings.HasPrefix(remainder, "=") { + return "", "", fmt.Errorf("indent-group must use the form indent-group=\"name\"") + } + remainder = strings.TrimLeft(strings.TrimPrefix(remainder, "="), "\t ") + if !strings.HasPrefix(remainder, "\"") { + value := strings.Fields(remainder) + unquotedValue := "" + if len(value) > 0 { + unquotedValue = value[0] + } + + return "", "", fmt.Errorf("indent-group value `%s` must be quoted", unquotedValue) + } + + quotedGroup, rest := consumeQuotedValue(remainder) + indentGroup, err := strconv.Unquote(quotedGroup) + if err != nil { + return "", "", fmt.Errorf("failed to unquote indent-group `%s`: %w", quotedGroup, err) + } + if indentGroup == "" { + return "", "", fmt.Errorf("indent-group must not be empty") + } + + return indentGroup, rest, nil +} + +// validateDeclarationRemainder rejects unconsumed marker attributes. +// +// Parameters: +// source - provides text after the parsed declaration. +// prefix - identifies the fragment marker for diagnostics. +// +// Returns an error when the remainder begins with an attribute-shaped token. +func validateDeclarationRemainder(source string, prefix string) error { + remainder := strings.TrimSpace(source) + if !startsWithAttribute(remainder) { + return nil + } + + return fmt.Errorf("unexpected attribute after `%s` declaration: `%s`", prefix, remainder) +} + +// startsWithAttribute reports whether source begins with an attribute-shaped token. +// +// Attribute names start with an ASCII letter, continue with ASCII letters, hyphens, +// or underscores, and may have horizontal whitespace before the equals sign. +// +// Parameters: +// source - provides unconsumed marker text. +// +// Returns true when source begins with a name followed by an equals sign. +func startsWithAttribute(source string) bool { + return attributePrefixPattern.MatchString(source) +} + +// consumeQuotedValue separates the first quoted string from the remaining marker text. +// +// Parameters: +// source - provides marker text beginning with a quoted string. +// +// Returns: +// string - quoted value, or the first unquoted token when no quoted value is present. +// string - unconsumed marker text. +func consumeQuotedValue(source string) (string, string) { + if !strings.HasPrefix(source, "\"") { + valueEnd := strings.IndexAny(source, ",\t \n") + if valueEnd < 0 { + return source, "" + } + + return source[:valueEnd], source[valueEnd:] + } + + escaped := false + for index := 1; index < len(source); index++ { + character := source[index] + if character == '"' && !escaped { + return source[:index+1], source[index+1:] + } + if character == '\\' { + escaped = !escaped + } else { + escaped = false + } + } + + return source, "" } // unquoteName removes quotes from a fragment marker name. @@ -117,11 +296,13 @@ func lookup(line string, prefix string) ([]string, error) { // string - unquoted fragment name. // error - when quotedName cannot be unquoted. func unquoteName(quotedName string) (string, error) { - nameQuoted := quotedNamePattern.FindString(quotedName) - nameCleaned, err := strconv.Unquote(nameQuoted) + nameCleaned, err := strconv.Unquote(quotedName) if err != nil { return "", fmt.Errorf("failed to unquote name `%s`: %w", quotedName, err) } + if nameCleaned == "" { + return "", fmt.Errorf("fragment name must not be empty") + } return nameCleaned, nil } diff --git a/fragmentation/partition.go b/fragmentation/partition.go index 816dbe77..dc32ba47 100644 --- a/fragmentation/partition.go +++ b/fragmentation/partition.go @@ -40,6 +40,9 @@ type Partition struct { // EndPosition is the last source-line index included in the partition. EndPosition int + + // IndentGroup identifies partitions whose common indentation is normalized together. + IndentGroup string } // NewPartition returns a Partition with both positions unset as -1. @@ -47,8 +50,8 @@ type Partition struct { // Returns an empty partition ready to receive start and end positions. func NewPartition() Partition { return Partition{ - -1, - -1, + StartPosition: -1, + EndPosition: -1, } } diff --git a/showcase/code/java/org/showcase/GroupedIndent.java b/showcase/code/java/org/showcase/GroupedIndent.java new file mode 100644 index 00000000..27931758 --- /dev/null +++ b/showcase/code/java/org/showcase/GroupedIndent.java @@ -0,0 +1,50 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package org.showcase; + +// #docfragment "Grouped example" indent-group="imports" +import java.util.List; +// #enddocfragment "Grouped example" + +public final class GroupedIndent { + private GroupedIndent() {} + + public static void print(List values) { + // #docfragment "Grouped example" + var first = values.get(0); + var normalized = first.trim(); + // #enddocfragment "Grouped example" + + // #docfragment "Grouped example" + var second = values.get(1); + // #enddocfragment "Grouped example" + + // #docfragment "Grouped example" + System.out.println(normalized + second); + // #enddocfragment "Grouped example" + } +} diff --git a/showcase/embedding/README.md b/showcase/embedding/README.md index 09432fb9..172935f1 100644 --- a/showcase/embedding/README.md +++ b/showcase/embedding/README.md @@ -39,6 +39,8 @@ go run ./main.go -mode=check -config-path=showcase/embedding/embed-code.yml embeds a region wrapped with `#docfragment` and `#enddocfragment` markers. - [multi-part-fragment-separator.md](positive/multi-part-fragment-separator.md) joins repeated fragment parts with the configured separator. +- [indent-groups.md](positive/indent-groups.md) + normalizes independently indented partitions without losing shared structure. - [overlapping-fragments.md](positive/overlapping-fragments.md) shows fragment markers that share source lines. diff --git a/showcase/embedding/positive/indent-groups.md b/showcase/embedding/positive/indent-groups.md new file mode 100644 index 00000000..612946ce --- /dev/null +++ b/showcase/embedding/positive/indent-groups.md @@ -0,0 +1,52 @@ +# Fragment Indentation Groups + +Use `indent-group` when a multi-part fragment combines independently indented +source regions. Each group receives its own common-indentation baseline, while +all partitions in that group preserve their indentation relative to one another. + +## How It Works + +Add `indent-group="name"` to an opening `#docfragment` marker. The group name +must be a non-empty quoted string. The matching `#enddocfragment` marker does +not accept the attribute; repeating it there is an error. No other marker +attributes are supported. + +Partitions without `indent-group` belong to one shared default group. Existing +fragment markers therefore keep the standard behavior of normalizing common +indentation across the complete fragment. + +In [GroupedIndent.java](../../code/java/org/showcase/GroupedIndent.java), the +import belongs to the `imports` group. The other three partitions use the +default group, so the top-level import does not affect their shared baseline: + +```java +// #docfragment "Grouped example" indent-group="imports" +import java.util.List; +// #enddocfragment "Grouped example" + +public static void print(List values) { + // #docfragment "Grouped example" + var first = values.get(0); + var normalized = first.trim(); + // #enddocfragment "Grouped example" + + // Two more "Grouped example" partitions use the default group. +} +``` + +## Embedding Instruction + +The embedding instruction still selects only the fragment name. Indentation +groups are source-marker metadata and require no instruction attribute. + + +```java +import java.util.List; +// ... +var first = values.get(0); + var normalized = first.trim(); +// ... +var second = values.get(1); +// ... +System.out.println(normalized + second); +``` diff --git a/showcase/embedding/positive/multi-part-fragment-separator.md b/showcase/embedding/positive/multi-part-fragment-separator.md index 16cb0fe4..e29fb401 100644 --- a/showcase/embedding/positive/multi-part-fragment-separator.md +++ b/showcase/embedding/positive/multi-part-fragment-separator.md @@ -17,6 +17,10 @@ The default separator is `...`. This showcase uses `// ...` in snippets. Separator indentation follows the surrounding rendered code, which keeps skipped sections readable inside classes and methods. +When selected parts come from independent indentation contexts, assign them +different [`indent-group`](indent-groups.md) values instead of allowing one +part to determine the common baseline for the others. + ## Embedding Instruction [MultiPartWorkflow.java](../../code/java/org/showcase/MultiPartWorkflow.java) diff --git a/showcase/embedding/positive/named-fragment.md b/showcase/embedding/positive/named-fragment.md index 4554487b..e206705b 100644 --- a/showcase/embedding/positive/named-fragment.md +++ b/showcase/embedding/positive/named-fragment.md @@ -11,6 +11,8 @@ A named fragment is declared in the source file with `#docfragment "name"` before the first line to include and `#enddocfragment "name"` after the last line to include. The marker text can sit inside the comment syntax of the source language, so Java uses `//`, Kotlin uses `//`, and HTML can use ``. +An opening marker may also declare an [`indent-group`](indent-groups.md) when a +multi-part fragment combines independently indented source regions. The `fragment` value in the embedding instruction must match the source marker name exactly. During embed mode or check mode, embed-code resolves the named diff --git a/test/resources/code/java/org/example/GroupedIndent.java b/test/resources/code/java/org/example/GroupedIndent.java new file mode 100644 index 00000000..af8a3b88 --- /dev/null +++ b/test/resources/code/java/org/example/GroupedIndent.java @@ -0,0 +1,28 @@ +package org.example; + +// #docfragment "Example" indent-group="imports" +import java.util.List; +// #enddocfragment "Example" + +public final class GroupedIndent { + private GroupedIndent() {} + + // #docfragment "Example" indent-group="imports" + static final String LABEL = "value"; + // #enddocfragment "Example" + + static void render(List values) { + // #docfragment "Example" + var first = values.get(0); + var nested = first.trim(); + // #enddocfragment "Example" + + // #docfragment "Example" + var second = values.get(1); + // #enddocfragment "Example" + + // #docfragment "Example" indent-group="output" + System.out.println(nested + second); + // #enddocfragment "Example" + } +}