diff --git a/EMBEDDING.md b/EMBEDDING.md index 327e923..70a5528 100644 --- a/EMBEDDING.md +++ b/EMBEDDING.md @@ -111,8 +111,74 @@ The tool supports an extended glob syntax for matching lines: By default, patterns imply a wildcard (`*`) at both the start and end. Use `^` and `$` to disable this behavior and match the exact line start or end. -If you need to match a literal `^` at the start of a line, use `^^`. -Similarly, use `$$` to match a literal `$` at the end of a line. +#### Multi-line patterns + +Use `\n` inside a `start`, `end`, or `line` pattern to match consecutive source lines. +Spaces around `\n` are ignored, and each pattern line uses the same glob syntax as a +regular one-line pattern. + +````markdown + +```java +``` +```` + +This matches a source range like: + +```java +@Test +@DisplayName("adds two values") +void addsTwoValues() { + int value = 1 + 1; + + assertEquals(2, value); +} +``` + +The `start` pattern above is interpreted as two consecutive line patterns: +`Test` and `adds two values`. Because ordinary patterns imply `*` at both ends, +these match `@Test` and `@DisplayName("adds two values")`. + +Use `^` and `$` on each pattern line when you need exact line matching: + +````markdown + +```java +``` +```` + +Without `\n`, a `start`, `end`, or `line` pattern matches only one source line. + +#### Escaping + +Use a backslash to match glob control characters literally. For example: + +- `\*` matches a literal `*`. +- `\?` matches a literal `?`. +- `\[` matches a literal `[`. + +Since `^` is only special at the start of a pattern, use `^^` to match a literal +`^` there. Since `$` is only special at the end of a pattern, use `$$` to match a +literal `$` there. + +To match literal `\n` text in a source line, write it as `\\n` in the pattern. + +````markdown + +```java +``` +```` + +It s possible to write quote characters in patterns as `\"` instead of the XML entity `"`. +For example, `line="println(\"Hello\")"` is equivalent to `line="println("Hello")"`. ## Comment filtering diff --git a/embedding/embedding_test.go b/embedding/embedding_test.go index f2857c3..8fc7119 100644 --- a/embedding/embedding_test.go +++ b/embedding/embedding_test.go @@ -197,6 +197,65 @@ var _ = Describe("Embedding", func() { Expect(processor.IsUpToDate()).Should(BeTrue()) }) + It("should embed a method with escaped newline patterns", func() { + config.DocIncludes = []string{"escaped-newline-pattern.md"} + docPath := fmt.Sprintf("%s/escaped-newline-pattern.md", config.DocumentationRoot) + processor := embedding.NewProcessor(docPath, config) + + Expect(processor.Embed()).Error().ShouldNot(HaveOccurred()) + + docContent, err := os.ReadFile(docPath) + Expect(err).ShouldNot(HaveOccurred()) + Expect(string(docContent)).Should(ContainSubstring("@Test\n" + + "@DisplayName(\"adds two values\")")) + Expect(string(docContent)).Should(ContainSubstring("assertEquals(2, value);\n}")) + Expect(string(docContent)).ShouldNot(ContainSubstring("subtractsTwoValues")) + }) + + It("should embed a method with exact escaped newline patterns", func() { + config.DocIncludes = []string{"escaped-newline-exact-pattern.md"} + docPath := fmt.Sprintf("%s/escaped-newline-exact-pattern.md", config.DocumentationRoot) + processor := embedding.NewProcessor(docPath, config) + + Expect(processor.Embed()).Error().ShouldNot(HaveOccurred()) + + docContent, err := os.ReadFile(docPath) + Expect(err).ShouldNot(HaveOccurred()) + Expect(string(docContent)).Should(ContainSubstring("@Test\n" + + "@DisplayName(\"adds two values\")")) + Expect(string(docContent)).Should(ContainSubstring("assertEquals(2, value);\n}")) + Expect(string(docContent)).ShouldNot(ContainSubstring("subtractsTwoValues")) + }) + + It("should embed matching lines with an escaped newline line pattern", func() { + config.DocIncludes = []string{"escaped-newline-line-pattern.md"} + docPath := fmt.Sprintf("%s/escaped-newline-line-pattern.md", config.DocumentationRoot) + processor := embedding.NewProcessor(docPath, config) + + Expect(processor.Embed()).Error().ShouldNot(HaveOccurred()) + + docContent, err := os.ReadFile(docPath) + Expect(err).ShouldNot(HaveOccurred()) + Expect(string(docContent)).Should(ContainSubstring("@Test\n" + + "@DisplayName(\"adds two values\")")) + Expect(string(docContent)).ShouldNot(ContainSubstring("void addsTwoValues")) + Expect(string(docContent)).ShouldNot(ContainSubstring("subtractsTwoValues")) + }) + + It("should embed a line with an escaped newline literal pattern", func() { + config.DocIncludes = []string{"escaped-newline-literal-pattern.md"} + docPath := fmt.Sprintf("%s/escaped-newline-literal-pattern.md", config.DocumentationRoot) + processor := embedding.NewProcessor(docPath, config) + + Expect(processor.Embed()).Error().ShouldNot(HaveOccurred()) + + docContent, err := os.ReadFile(docPath) + Expect(err).ShouldNot(HaveOccurred()) + Expect(string(docContent)).Should(ContainSubstring( + "private static final String LINE_SEPARATOR = \"\\n\";", + )) + }) + It("should report a missing closing tag", func() { docPath := fmt.Sprintf("%s/missing-closing-tag.md", config.DocumentationRoot) processor := embedding.NewProcessor(docPath, config) diff --git a/embedding/parsing/instruction.go b/embedding/parsing/instruction.go index 8628455..8cc23c8 100644 --- a/embedding/parsing/instruction.go +++ b/embedding/parsing/instruction.go @@ -191,6 +191,18 @@ func (e Instruction) String() string { // lines — a list of strings representing the input lines. func (e Instruction) matchingLines(lines []string, codeFileReference string) ([]string, error) { if e.LinePattern != nil { + if e.LinePattern.HasLineSeparator() { + startPosition, endPosition, err := e.matchLineSequence( + e.LinePattern, lines, 0, "line", codeFileReference, + ) + if err != nil { + return nil, err + } + requiredLines := lines[startPosition : endPosition+1] + indentation := indent.MaxCommonIndentation(requiredLines) + + return indent.CutIndent(requiredLines, indentation), nil + } linePosition, err := e.matchGlob( e.LinePattern, lines, 0, "line", codeFileReference, ) @@ -238,19 +250,71 @@ func (e Instruction) matchingLines(lines []string, codeFileReference string) ([] // startFrom — an index from which to start searching. func (e Instruction) matchGlob(pattern *Pattern, lines []string, startFrom int, kind string, codeFileReference string) (int, error) { + if pattern.HasLineSeparator() { + start, end, err := e.matchLineSequence( + pattern, lines, startFrom, kind, codeFileReference, + ) + if err != nil { + return 0, err + } + if kind == "end" { + return end, nil + } + return start, nil + } + if line, found := matchSingleLine(pattern, lines, startFrom); found { + return line, nil + } + return 0, PatternNotFoundError{ + Line: e.DocumentationLine, + CodeFileReference: codeFileReference, + Kind: kind, + Pattern: pattern, + } +} + +// matchSingleLine returns the first source line matching the pattern. +func matchSingleLine(pattern *Pattern, lines []string, startFrom int) (int, bool) { lineCount := len(lines) resultLine := startFrom for resultLine < lineCount { line := lines[resultLine] if pattern.Match(line) { - return resultLine, nil + return resultLine, true } resultLine++ } - return 0, PatternNotFoundError{ + + return 0, false +} + +// matchLineSequence returns the first line range matching the pattern or a not-found error. +func (e Instruction) matchLineSequence(pattern *Pattern, lines []string, startFrom int, + kind string, codeFileReference string) (int, int, error) { + start, end, found := matchLineSequence(pattern, lines, startFrom) + if found { + return start, end, nil + } + + return 0, 0, PatternNotFoundError{ Line: e.DocumentationLine, CodeFileReference: codeFileReference, Kind: kind, Pattern: pattern, } } + +// matchLineSequence returns the first source-line range matching an escaped-line pattern. +func matchLineSequence(pattern *Pattern, lines []string, startFrom int) (int, int, bool) { + patternLines, _ := pattern.linePatterns() + lineCount := len(patternLines) + lastStart := len(lines) - lineCount + for start := startFrom; start <= lastStart; start++ { + end := start + lineCount + if pattern.MatchLineSequence(lines[start:end]) { + return start, end - 1, true + } + } + + return 0, 0, false +} diff --git a/embedding/parsing/instruction_test.go b/embedding/parsing/instruction_test.go index 0425f98..1fd0d2c 100644 --- a/embedding/parsing/instruction_test.go +++ b/embedding/parsing/instruction_test.go @@ -85,6 +85,15 @@ var _ = Describe("Instruction", func() { Expect(parsing.FromXML(xmlString, config)).Error().ShouldNot(HaveOccurred()) }) + It("should parse backslash-escaped quotes in XML attributes", func() { + xmlString := `` + + attributes, err := parsing.ParseXMLLine(xmlString) + + Expect(err).ShouldNot(HaveOccurred()) + Expect(attributes["line"]).Should(Equal(`println("Hello world")`)) + }) + It("should have an error for unsupported comments mode", func() { instructionParams := TestInstructionParams{ comments: "summary", @@ -301,6 +310,45 @@ var _ = Describe("Instruction", func() { })) }) + It("should embed a line with an escaped asterisk pattern", func() { + instructionParams := TestInstructionParams{ + lineGlob: `Use \* to multiply`, + } + + actualLines := getXMLExtractionContent( + "literal-patterns.txt", instructionParams, config) + + Expect(actualLines).Should(Equal([]string{ + "Use * to multiply", + })) + }) + + It("should embed a line starting with a literal caret pattern", func() { + instructionParams := TestInstructionParams{ + lineGlob: "^^ starts with caret", + } + + actualLines := getXMLExtractionContent( + "literal-patterns.txt", instructionParams, config) + + Expect(actualLines).Should(Equal([]string{ + "^ starts with caret", + })) + }) + + It("should embed a line ending with a literal dollar pattern", func() { + instructionParams := TestInstructionParams{ + lineGlob: "The value ends with $$", + } + + actualLines := getXMLExtractionContent( + "literal-patterns.txt", instructionParams, config) + + Expect(actualLines).Should(Equal([]string{ + "The value ends with $", + })) + }) + It("should successfully parse XML by only end glob", func() { instructionParams := TestInstructionParams{ endGlob: "package*", diff --git a/embedding/parsing/pattern.go b/embedding/parsing/pattern.go index 07a6424..a754b54 100644 --- a/embedding/parsing/pattern.go +++ b/embedding/parsing/pattern.go @@ -41,6 +41,8 @@ const ( anyCharacterSequence = "*" lineStart = "^" lineEnd = "$" + lineSeparator = `\n` + escapedLineSeparator = `\\n` ) // NewPattern creates a new Pattern based on provided glob string. @@ -51,6 +53,12 @@ const ( // The modified pattern is the original one, but enclosed with the "*" wildcards, // unless start of the line or end of the line wildcards were specified. // +// A multi-line pattern uses "\n" as a separator between consecutive source-line +// patterns. For example, "Test \n adds two values" matches a line matching "Test" +// followed by a line matching "adds two values". Each part separated by "\n" is +// converted to Pattern separately and follows the same wildcard rules. +// Use "\\n" to match literal "\n" text instead of starting the next pattern line. +// // glob — a string that represents a pattern that can include such wildcards: // - "*" — matches any sequence of characters; // - "^" — matches the start of the line; @@ -100,6 +108,55 @@ func (p Pattern) Match(line string) bool { return g.Match(line) } +// HasLineSeparator reports whether the pattern contains an escaped line separator. +func (p Pattern) HasLineSeparator() bool { + _, hasSeparator := p.linePatterns() + + return hasSeparator +} + +// MatchLineSequence reports whether source lines match the escaped-line-separated pattern. +func (p Pattern) MatchLineSequence(lines []string) bool { + patternLines, _ := p.linePatterns() + if len(patternLines) != len(lines) { + return false + } + for i, patternLine := range patternLines { + pattern := NewPattern(patternLine) + if !pattern.Match(lines[i]) { + return false + } + } + + return true +} + +// linePatterns returns trimmed pattern lines separated by an escaped newline. +func (p Pattern) linePatterns() ([]string, bool) { + var patternLines []string + var line strings.Builder + hasSeparator := false + for i := 0; i < len(p.sourceGlob); { + remaining := p.sourceGlob[i:] + switch { + case strings.HasPrefix(remaining, escapedLineSeparator): + line.WriteString(escapedLineSeparator) + i += len(escapedLineSeparator) + case strings.HasPrefix(remaining, lineSeparator): + patternLines = append(patternLines, strings.TrimSpace(line.String())) + line.Reset() + hasSeparator = true + i += len(lineSeparator) + default: + line.WriteByte(p.sourceGlob[i]) + i++ + } + } + patternLines = append(patternLines, strings.TrimSpace(line.String())) + + return patternLines, hasSeparator +} + // Returns string representation of Pattern. func (p Pattern) String() string { return fmt.Sprintf("Pattern %s", p.sourceGlob) diff --git a/embedding/parsing/xml_parse.go b/embedding/parsing/xml_parse.go index 9d4dcda..6716242 100644 --- a/embedding/parsing/xml_parse.go +++ b/embedding/parsing/xml_parse.go @@ -22,6 +22,7 @@ import ( "embed-code/embed-code-go/configuration" "encoding/xml" "fmt" + "strings" ) // Item needed for xml.Unmarshal parsing. The fields are filling up during the parsing. @@ -69,7 +70,7 @@ func FromXML(line string, config configuration.Configuration) (Instruction, erro // Returns a map of key-value pairs. If the provided line is not valid, returns an error. func ParseXMLLine(xmlLine string) (map[string]string, error) { var root Item - err := xml.Unmarshal([]byte(xmlLine), &root) + err := xml.Unmarshal([]byte(quoteEscapedXMLLine(xmlLine)), &root) if err != nil { return map[string]string{}, err } @@ -86,3 +87,8 @@ func ParseXMLLine(xmlLine string) (map[string]string, error) { return attributes, nil } + +// quoteEscapedXMLLine converts backslash-escaped quotes into XML entities. +func quoteEscapedXMLLine(xmlLine string) string { + return strings.ReplaceAll(xmlLine, `\"`, """) +} diff --git a/main.go b/main.go index b08fad5..c09df6e 100644 --- a/main.go +++ b/main.go @@ -28,7 +28,7 @@ import ( ) // Version of the embed-code application. -const Version = "1.2.0" +const Version = "1.2.1" // The entry point for embed-code. // diff --git a/test/resources/code/java/literal-patterns.txt b/test/resources/code/java/literal-patterns.txt new file mode 100644 index 0000000..ca45d08 --- /dev/null +++ b/test/resources/code/java/literal-patterns.txt @@ -0,0 +1,4 @@ +Use * to multiply +The total is $5 +The value ends with $ +^ starts with caret diff --git a/test/resources/code/java/org/example/MultiLinePatternSample.java b/test/resources/code/java/org/example/MultiLinePatternSample.java new file mode 100644 index 0000000..bcf6e19 --- /dev/null +++ b/test/resources/code/java/org/example/MultiLinePatternSample.java @@ -0,0 +1,25 @@ +package org.example; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class MultiLinePatternSample { + + private static final String LINE_SEPARATOR = "\n"; + + @Test + @DisplayName("adds two values") + void addsTwoValues() { + int value = 1 + 1; + + assertEquals(2, value); + } + + @Test + @DisplayName("subtracts two values") + void subtractsTwoValues() { + int value = 2 - 1; + + assertEquals(1, value); + } +} diff --git a/test/resources/docs/escaped-newline-exact-pattern.md b/test/resources/docs/escaped-newline-exact-pattern.md new file mode 100644 index 0000000..04417e4 --- /dev/null +++ b/test/resources/docs/escaped-newline-exact-pattern.md @@ -0,0 +1,7 @@ +# Escaped-newline exact pattern + + +```java +``` diff --git a/test/resources/docs/escaped-newline-line-pattern.md b/test/resources/docs/escaped-newline-line-pattern.md new file mode 100644 index 0000000..8fd0a33 --- /dev/null +++ b/test/resources/docs/escaped-newline-line-pattern.md @@ -0,0 +1,6 @@ +# Escaped-newline line pattern + + +```java +``` diff --git a/test/resources/docs/escaped-newline-literal-pattern.md b/test/resources/docs/escaped-newline-literal-pattern.md new file mode 100644 index 0000000..9a50459 --- /dev/null +++ b/test/resources/docs/escaped-newline-literal-pattern.md @@ -0,0 +1,6 @@ +# Escaped-newline literal pattern + + +```java +``` diff --git a/test/resources/docs/escaped-newline-pattern.md b/test/resources/docs/escaped-newline-pattern.md new file mode 100644 index 0000000..01081f6 --- /dev/null +++ b/test/resources/docs/escaped-newline-pattern.md @@ -0,0 +1,7 @@ +# Escaped-newline pattern + + +```java +```