diff --git a/embedding/embedding_test.go b/embedding/embedding_test.go index 6687ac6..d30a421 100644 --- a/embedding/embedding_test.go +++ b/embedding/embedding_test.go @@ -165,6 +165,35 @@ var _ = Describe("Embedding", func() { )) }) + It("should report all pattern matching errors", func() { + config.DocIncludes = []string{"missing-start-pattern.md", "missing-end-pattern.md"} + + var recovered any + func() { + defer func() { + recovered = recover() + }() + embedding.CheckUpToDate(config) + }() + + Expect(recovered).ShouldNot(BeNil()) + Expect(fmt.Sprint(recovered)).Should(And( + ContainSubstring("missing-start-pattern.md:3"), + ContainSubstring( + "no line in code file `file://", + ), + ContainSubstring( + "` matches the start pattern "+ + "`*doesNotExistStart*`", + ), + ContainSubstring("missing-end-pattern.md:3"), + ContainSubstring( + "` matches the end pattern "+ + "`*doesNotExistEnd*`", + ), + )) + }) + It("should embed with multi lined tag attributes", func() { docPath := fmt.Sprintf("%s/multi-lined-valid-tag-attributes.md", config.DocumentationRoot) processor := embedding.NewProcessor(docPath, config) @@ -201,6 +230,32 @@ var _ = Describe("Embedding", func() { )) }) + It("should report a missing code fence after the instruction", func() { + docPath := fmt.Sprintf("%s/missing-code-fence.md", config.DocumentationRoot) + processor := embedding.NewProcessor(docPath, config) + + _, err := processor.Embed() + + Expect(err).Should(HaveOccurred()) + Expect(err.Error()).Should(ContainSubstring( + "missing-code-fence.md:3`: " + + "expected a markdown code fence after the embedding instruction", + )) + }) + + It("should report an unclosed code fence after the instruction", func() { + docPath := fmt.Sprintf("%s/unclosed-code-fence.md", config.DocumentationRoot) + processor := embedding.NewProcessor(docPath, config) + + _, err := processor.Embed() + + Expect(err).Should(HaveOccurred()) + Expect(err.Error()).Should(ContainSubstring( + "unclosed-code-fence.md:3`: " + + "the markdown code fence after the embedding instruction is not closed", + )) + }) + // TODO:olena-zmiiova:https://github.com/SpineEventEngine/embed-code/issues/65 It("should successfully embed to a file in a nested dir", func() { Skip( diff --git a/embedding/parsing/blank_line.go b/embedding/parsing/blank_line.go index 9d43a81..d529080 100644 --- a/embedding/parsing/blank_line.go +++ b/embedding/parsing/blank_line.go @@ -32,8 +32,7 @@ type BlankLineState struct{} // Checks if the current line is empty and not part of a code fence, and if there is an embedding. // If these conditions are met, it returns true. Otherwise, it returns false. func (b BlankLineState) Recognize(context Context) bool { - isEmptyString := strings.TrimSpace(context.CurrentLine()) == "" - if !context.ReachedEOF() && isEmptyString { + if !context.ReachedEOF() && strings.TrimSpace(context.CurrentLine()) == "" { return !context.CodeFenceStarted && context.EmbeddingInstruction != nil } diff --git a/embedding/parsing/context.go b/embedding/parsing/context.go index 8eecb90..4629383 100644 --- a/embedding/parsing/context.go +++ b/embedding/parsing/context.go @@ -163,7 +163,7 @@ func (c *Context) ResolveUnacceptedEmbedding() { currentEmbeddingInstruction := c.CurrentEmbedding().embeddingInstruction c.UnacceptedEmbeddings = append(c.UnacceptedEmbeddings, currentEmbeddingInstruction) c.embeddings = c.embeddings[:c.currentEmbeddingIndex()] - c.SetEmbedding(nil) + c.EmbeddingInstruction = nil } // SetEmbedding sets an embedding to Context. Also sets fileContainsEmbedding flag. diff --git a/embedding/parsing/instruction.go b/embedding/parsing/instruction.go index 13e6089..d061cd8 100644 --- a/embedding/parsing/instruction.go +++ b/embedding/parsing/instruction.go @@ -61,6 +61,29 @@ type Instruction struct { Configuration configuration.Configuration } +// PatternNotFoundError reports that a start or end pattern did not match the code file. +type PatternNotFoundError struct { + Line int + CodeFileReference string + Kind string + Pattern *Pattern +} + +// Error returns a user-facing description of an unmatched start or end pattern. +func (e PatternNotFoundError) Error() string { + pattern := "" + if e.Pattern != nil { + pattern = e.Pattern.sourceGlob + } + + return fmt.Sprintf( + "no line in code file `%s` matches the %s pattern `%s`", + e.CodeFileReference, + e.Kind, + pattern, + ) +} + // NewInstruction creates an Instruction based on provided attributes and configuration. // // attributes — a map with string-typed both keys and values. Possible keys are: @@ -121,7 +144,14 @@ func (e Instruction) Content() ([]string, error) { return nil, err } if e.StartPattern != nil || e.EndPattern != nil { - fileContent = e.matchingLines(fileContent) + codeFileReference, err := fragmentation.ResolveCodeFileReference(e.CodeFile, e.Configuration) + if err != nil { + return nil, err + } + fileContent, err = e.matchingLines(fileContent, codeFileReference) + if err != nil { + return nil, err + } } return commentfilter.Filter( @@ -144,19 +174,31 @@ func (e Instruction) String() string { // Filters and returns a subset of input lines based on start and end patterns. // // lines — a list of strings representing the input lines. -func (e Instruction) matchingLines(lines []string) []string { +func (e Instruction) matchingLines(lines []string, codeFileReference string) ([]string, error) { startPosition := 0 if e.StartPattern != nil { - startPosition = e.matchGlob(e.StartPattern, lines, 0) + var err error + startPosition, err = e.matchGlob( + e.StartPattern, lines, 0, "start", codeFileReference, + ) + if err != nil { + return nil, err + } } endPosition := len(lines) - 1 if e.EndPattern != nil { - endPosition = e.matchGlob(e.EndPattern, lines, startPosition) + var err error + endPosition, err = e.matchGlob( + e.EndPattern, lines, startPosition, "end", codeFileReference, + ) + if err != nil { + return nil, err + } } requiredLines := lines[startPosition : endPosition+1] indentation := indent.MaxCommonIndentation(requiredLines) - return indent.CutIndent(requiredLines, indentation) + return indent.CutIndent(requiredLines, indentation), nil } // Returns the index of a first line that matches given pattern. @@ -166,15 +208,21 @@ func (e Instruction) matchingLines(lines []string) []string { // lines — a list of lines to search in. // // startFrom — an index from which to start searching. -func (e Instruction) matchGlob(pattern *Pattern, lines []string, startFrom int) int { +func (e Instruction) matchGlob(pattern *Pattern, lines []string, startFrom int, + kind string, codeFileReference string) (int, error) { lineCount := len(lines) resultLine := startFrom for resultLine < lineCount { line := lines[resultLine] if pattern.Match(line) { - return resultLine + return resultLine, nil } resultLine++ } - panic(fmt.Sprintf("there is no line matching `%s`", pattern)) + return 0, PatternNotFoundError{ + Line: e.DocumentationLine, + CodeFileReference: codeFileReference, + Kind: kind, + Pattern: pattern, + } } diff --git a/embedding/parsing/instruction_test.go b/embedding/parsing/instruction_test.go index 9676f44..8361dd0 100644 --- a/embedding/parsing/instruction_test.go +++ b/embedding/parsing/instruction_test.go @@ -22,6 +22,7 @@ import ( _type "embed-code/embed-code-go/type" "fmt" "os" + "path/filepath" "strings" "testing" @@ -343,7 +344,7 @@ var _ = Describe("Instruction", func() { Expect(actualLines[5]).Should(Equal(expectedLastLine)) }) - It("should panic when start glob does not match", func() { + It("should report an error when start glob does not match", func() { instructionParams := TestInstructionParams{ startGlob: "foo bar", endGlob: "*main*", @@ -351,15 +352,17 @@ var _ = Describe("Instruction", func() { xmlString := buildInstruction("org/example/Hello.java", instructionParams) instruction := createInstructionFromXML(xmlString, config) - Expect(func() { - _, err := instruction.Content() - if err != nil { - return - } - }).To(Panic()) + _, err := instruction.Content() + + Expect(err).Should(MatchError( + fmt.Sprintf( + "no line in code file `file://%s` matches the start pattern `foo bar`", + absTestCodeFile("org/example/Hello.java"), + ), + )) }) - It("should panic when end glob does not match", func() { + It("should report an error when end glob does not match", func() { instructionParams := TestInstructionParams{ startGlob: "*main*", endGlob: "foo bar", @@ -367,12 +370,14 @@ var _ = Describe("Instruction", func() { xmlString := buildInstruction("org/example/Hello.java", instructionParams) instruction := createInstructionFromXML(xmlString, config) - Expect(func() { - _, err := instruction.Content() - if err != nil { - return - } - }).To(Panic()) + _, err := instruction.Content() + + Expect(err).Should(MatchError( + fmt.Sprintf( + "no line in code file `file://%s` matches the end pattern `foo bar`", + absTestCodeFile("org/example/Hello.java"), + ), + )) }) }) @@ -436,6 +441,15 @@ func readInstructionContent(instruction parsing.Instruction) []string { return lines } +func absTestCodeFile(path string) string { + absolutePath, err := filepath.Abs(filepath.Join("../../test/resources/code/java", path)) + if err != nil { + Fail("unexpected error while resolving test code file: " + err.Error()) + } + + return absolutePath +} + func xmlAttribute(name string, value string) string { return fmt.Sprintf("%s=\"%v\"", name, value) } diff --git a/embedding/parsing/instruction_token.go b/embedding/parsing/instruction_token.go index 8a85c52..de0f2c3 100644 --- a/embedding/parsing/instruction_token.go +++ b/embedding/parsing/instruction_token.go @@ -36,6 +36,16 @@ type InstructionParseError struct { Reason string } +// MissingCodeFenceError reports that an embedding instruction is not followed by a code fence. +type MissingCodeFenceError struct { + Line int +} + +// UnclosedCodeFenceError reports that an embedding code fence is not closed. +type UnclosedCodeFenceError struct { + Line int +} + // Error returns a user-facing description of an embedding instruction parse failure. func (e InstructionParseError) Error() string { return fmt.Sprintf( @@ -44,6 +54,16 @@ func (e InstructionParseError) Error() string { ) } +// Error returns a user-facing description of a missing code fence after an instruction. +func (e MissingCodeFenceError) Error() string { + return "expected a markdown code fence after the embedding instruction" +} + +// Error returns a user-facing description of an unclosed embedding code fence. +func (e UnclosedCodeFenceError) Error() string { + return "the markdown code fence after the embedding instruction is not closed" +} + // Recognize reports whether the current line in the parsing context starts with " 0 { return context.CurrentEmbedding().SourceStartIndex - 1 } @@ -256,6 +271,22 @@ func errorLine(context parsing.Context, err error) int { return context.CurrentIndex() } +// unacceptedTransitionError explains why the parser could not accept the current state. +func unacceptedTransitionError(context parsing.Context) error { + if context.EmbeddingInstruction != nil && context.CodeFenceStarted { + return parsing.UnclosedCodeFenceError{ + Line: context.EmbeddingInstruction.DocumentationLine, + } + } + if context.EmbeddingInstruction != nil && !context.CodeFenceStarted { + return parsing.MissingCodeFenceError{ + Line: context.EmbeddingInstruction.DocumentationLine, + } + } + + return fmt.Errorf("unexpected parser state at line %d", context.CurrentIndex()) +} + // Moves to the next state accordingly to a transition map from the current state. Reports whether // it successfully moved to the next state and returns the new state. func (p Processor) moveToNextState(state *parsing.State, context *parsing.Context) ( diff --git a/fragmentation/resolver.go b/fragmentation/resolver.go index 84f26ec..bcf4271 100644 --- a/fragmentation/resolver.go +++ b/fragmentation/resolver.go @@ -73,6 +73,19 @@ func ResolveContent(codePath string, fragmentName string, config config.Configur return fragmentLines(fragment, content.lines, config.Separator), nil } +// ResolveCodeFileReference returns a user-facing reference to the source file. +func ResolveCodeFileReference(codePath string, config config.Configuration) (string, error) { + source, found, err := resolveSource(codePath, config) + if err != nil { + return "", err + } + if found { + return "file://" + source.absolutePath, nil + } + + return codeFileReference(codePath, config) +} + // ClearResolverCache removes cached source fragmentations. func ClearResolverCache() { resolverCache.clear() diff --git a/test/resources/docs/missing-code-fence.md b/test/resources/docs/missing-code-fence.md new file mode 100644 index 0000000..2162160 --- /dev/null +++ b/test/resources/docs/missing-code-fence.md @@ -0,0 +1,4 @@ +# Example without a code fence + + +public static void main(String[] args) { diff --git a/test/resources/docs/missing-end-pattern.md b/test/resources/docs/missing-end-pattern.md new file mode 100644 index 0000000..9860297 --- /dev/null +++ b/test/resources/docs/missing-end-pattern.md @@ -0,0 +1,5 @@ +# Example with a missing end pattern + + +```java +``` diff --git a/test/resources/docs/missing-start-pattern.md b/test/resources/docs/missing-start-pattern.md new file mode 100644 index 0000000..b967b55 --- /dev/null +++ b/test/resources/docs/missing-start-pattern.md @@ -0,0 +1,5 @@ +# Example with a missing start pattern + + +```java +``` diff --git a/test/resources/docs/unclosed-code-fence.md b/test/resources/docs/unclosed-code-fence.md new file mode 100644 index 0000000..30c9eeb --- /dev/null +++ b/test/resources/docs/unclosed-code-fence.md @@ -0,0 +1,5 @@ +# Example with an unclosed code fence + + +```java +public static void main(String[] args) {