diff --git a/embedding/embedding_test.go b/embedding/embedding_test.go index b0ddb12..77dd383 100644 --- a/embedding/embedding_test.go +++ b/embedding/embedding_test.go @@ -131,7 +131,9 @@ var _ = Describe("Embedding", func() { Expect(err).Should(HaveOccurred()) Expect(err.Error()).Should(And( ContainSubstring("missing-closing-tag.md"), - ContainSubstring("the `` tag is not closed"), + ContainSubstring( + "the `` instruction is not closed; add `` or use `/>`", + ), ContainSubstring("unclosed-nested-tag.md"), ContainSubstring("element closed by "), )) @@ -245,7 +247,7 @@ var _ = Describe("Embedding", func() { Expect(err.Error()).Should(ContainSubstring( "missing-closing-tag.md:3`: " + "failed to parse an embedding instruction: " + - "the `` tag is not closed", + "the `` instruction is not closed; add `` or use `/>`", )) }) @@ -264,7 +266,9 @@ var _ = Describe("Embedding", func() { var parseErr parsing.InstructionParseError Expect(errors.As(err, &parseErr)).Should(BeTrue()) Expect(parseErr.Line).Should(Equal(3)) - Expect(parseErr.Reason).Should(Equal("the `` tag is not closed")) + Expect(parseErr.Reason).Should(Equal( + "the `` instruction is not closed; add `` or use `/>`", + )) }) It("should report the XML parser error", func() { @@ -294,6 +298,28 @@ var _ = Describe("Embedding", func() { )) }) + It("should report a missing file attribute with documentation context", func() { + docPath := testDocPath(config, "missing-file-attribute.md") + Expect(os.WriteFile( + docPath, + []byte("# Missing file attribute\n\n"+ + "\n"+ + "```java\n"+ + "```\n"), + 0600, + )).To(Succeed()) + processor := newProcessor(docPath, config) + + _, err := processor.Embed() + + Expect(err).Should(HaveOccurred()) + Expect(err.Error()).Should(ContainSubstring( + "missing-file-attribute.md:3`: " + + "failed to parse an embedding instruction: " + + " must specify a non-empty `file` attribute", + )) + }) + It("should report an unclosed code fence after the instruction", func() { docPath := testDocPath(config, "unclosed-code-fence.md") processor := newProcessor(docPath, config) diff --git a/embedding/parsing/instruction.go b/embedding/parsing/instruction.go index 3b4a315..88ec8f0 100644 --- a/embedding/parsing/instruction.go +++ b/embedding/parsing/instruction.go @@ -29,6 +29,7 @@ package parsing import ( "fmt" "log/slog" + "slices" "strings" "embed-code/embed-code-go/configuration" @@ -37,6 +38,15 @@ import ( "embed-code/embed-code-go/indent" ) +var supportedInstructionAttributes = []string{ + "comments", + "end", + "file", + "fragment", + "line", + "start", +} + // Instruction specifies the code fragment to embed into a Markdown file. // // It is parsed from an XML-like `` instruction such as @@ -123,6 +133,10 @@ func (e PatternNotFoundError) Error() string { // error - when instruction attributes are invalid. func NewInstruction( attributes map[string]string, config configuration.Configuration) (Instruction, error) { + if err := validateInstructionAttributes(attributes); err != nil { + return Instruction{}, err + } + codeFile := attributes["file"] fragment := attributes["fragment"] startValue := attributes["start"] @@ -153,6 +167,35 @@ func NewInstruction( }, nil } +// validateInstructionAttributes reports unsupported or missing instruction attributes. +func validateInstructionAttributes(attributes map[string]string) error { + var unsupported []string + for attribute := range attributes { + if !slices.Contains(supportedInstructionAttributes, attribute) { + unsupported = append(unsupported, attribute) + } + } + if len(unsupported) > 0 { + slices.Sort(unsupported) + attributeLabel := "attribute" + if len(unsupported) > 1 { + attributeLabel = "attributes" + } + + return fmt.Errorf( + "unsupported %s `%s`; expected one of `%s`", + attributeLabel, + strings.Join(unsupported, "`, `"), + strings.Join(supportedInstructionAttributes, "`, `"), + ) + } + if strings.TrimSpace(attributes["file"]) == "" { + return fmt.Errorf(" must specify a non-empty `file` attribute") + } + + return nil +} + // validateExclusiveAttributes reports mutually exclusive instruction attributes. func validateExclusiveAttributes(fragment string, start string, end string, line string) error { if fragment != "" && (start != "" || end != "" || line != "") { diff --git a/embedding/parsing/instruction_test.go b/embedding/parsing/instruction_test.go index e048a23..ac0ffc1 100644 --- a/embedding/parsing/instruction_test.go +++ b/embedding/parsing/instruction_test.go @@ -126,6 +126,51 @@ var _ = Describe("Instruction", func() { Expect(parsing.FromXML(xmlString, config)).Error().Should(HaveOccurred()) }) + It("should report a missing file attribute", func() { + xmlString := `` + + _, err := parsing.FromXML(xmlString, config) + + Expect(err).Should(MatchError( + " must specify a non-empty `file` attribute", + )) + }) + + It("should report an unsupported instruction attribute", func() { + xmlString := `` + + _, err := parsing.FromXML(xmlString, config) + + Expect(err).Should(MatchError( + "unsupported attribute `fil`; " + + "expected one of `comments`, `end`, `file`, `fragment`, `line`, `start`", + )) + }) + + It("should report unsupported instruction attributes", func() { + xmlString := `` + + _, err := parsing.FromXML(xmlString, config) + + Expect(err).Should(MatchError( + "unsupported attributes `fil`, `unknown`; " + + "expected one of `comments`, `end`, `file`, `fragment`, `line`, `start`", + )) + }) + + It("should report a mistyped instruction tag", func() { + xmlString := `` + + _, err := parsing.FromXML(xmlString, config) + + Expect(err).Should(MatchError( + "expected `` instruction tag, got ``", + )) + }) + It("should have an error for an invalid glob pattern", func() { instructionParams := TestInstructionParams{ startGlob: "[", diff --git a/embedding/parsing/instruction_token.go b/embedding/parsing/instruction_token.go index ab4c3b6..e55aab4 100644 --- a/embedding/parsing/instruction_token.go +++ b/embedding/parsing/instruction_token.go @@ -142,13 +142,22 @@ func (e EmbedInstructionTokenState) Accept(context *Context, // parseFailureReason explains why an embedding instruction could not be parsed. func parseFailureReason(instructionBody []string, parseErr error) string { - instruction := strings.TrimSpace(strings.Join(instructionBody, " ")) - if !strings.Contains(instruction, "/>") && - !strings.Contains(instruction, "") { - return fmt.Sprintf("the `<%s>` tag is not closed", + var wrongTagErr WrongInstructionTagError + if errors.As(parseErr, &wrongTagErr) { + return wrongTagErr.Error() + } + if !openingTagClosed(instructionBody) { + return fmt.Sprintf("the opening `<%s>` tag is not closed; add `>` or `/>` before the code fence", + EmbeddingTag, + ) + } + if !instructionClosed(instructionBody) { + return fmt.Sprintf("the `<%s>` instruction is not closed; add `` or use `/>`", + EmbeddingTag, EmbeddingTag, ) } + if parseErr != nil { var syntaxErr *xml.SyntaxError if errors.As(parseErr, &syntaxErr) { @@ -160,3 +169,124 @@ func parseFailureReason(instructionBody []string, parseErr error) string { return "invalid embedding instruction" } + +// openingTagClosed reports whether the first embed-code opening tag reaches `>`. +func openingTagClosed(instructionBody []string) bool { + return scanInstructionBody(instructionBody, func(remaining string) (bool, bool) { + switch { + case strings.HasPrefix(remaining, ">"): + return true, true + case strings.HasPrefix(remaining, ""): + return true, true + case strings.HasPrefix(remaining, ""): + return true, true + default: + return false, false + } + }) +} + +// scanInstructionBody scans instruction text outside quoted attribute values. +func scanInstructionBody( + instructionBody []string, + check func(remaining string) (bool, bool), +) bool { + inTag := false + inQuote := false + var quote byte + for _, line := range instructionBody { + if inTag && !inQuote && markdownCodeFenceStart(line) { + return false + } + matched, done := scanInstructionLine(line, check, &inTag, &inQuote, "e) + if done { + return matched + } + } + + return false +} + +// scanInstructionLine scans one instruction line outside quoted attribute values. +func scanInstructionLine( + line string, + check func(remaining string) (bool, bool), + inTag *bool, + inQuote *bool, + quote *byte, +) (bool, bool) { + for lineCursor := 0; lineCursor < len(line); lineCursor++ { + if !*inTag { + if !startInstructionScan(line, &lineCursor, inTag) { + break + } + + continue + } + if *inQuote { + skipQuotedCharacter(line, &lineCursor, inQuote, quote) + + continue + } + if line[lineCursor] == '"' || line[lineCursor] == '\'' { + *inQuote = true + *quote = line[lineCursor] + + continue + } + matched, done := check(line[lineCursor:]) + if done { + return matched, true + } + } + + return false, false +} + +// startInstructionScan moves lineCursor to the first embed-code tag. +func startInstructionScan(line string, lineCursor *int, inTag *bool) bool { + tagStart := strings.Index(line[*lineCursor:], "<"+EmbeddingTag) + if tagStart < 0 { + return false + } + *lineCursor += tagStart + len("<"+EmbeddingTag) - 1 + *inTag = true + + return true +} + +// skipQuotedCharacter updates quote state while scanning an attribute value. +func skipQuotedCharacter(line string, lineCursor *int, inQuote *bool, quote *byte) { + if line[*lineCursor] == '\\' { + *lineCursor++ + + return + } + if line[*lineCursor] == *quote { + *inQuote = false + } +} + +// markdownCodeFenceStart reports whether a line starts a Markdown code fence. +func markdownCodeFenceStart(line string) bool { + trimmedLine := strings.TrimSpace(line) + if !strings.HasPrefix(trimmedLine, "```") { + return false + } + + return len(codeFenceMarker(trimmedLine)) >= minCodeFenceMarkerLength +} diff --git a/embedding/parsing/state_test.go b/embedding/parsing/state_test.go index c2452b0..3301724 100644 --- a/embedding/parsing/state_test.go +++ b/embedding/parsing/state_test.go @@ -111,7 +111,9 @@ var _ = Describe("Parser states", func() { var parseErr parsing.InstructionParseError Expect(errors.As(err, &parseErr)).Should(BeTrue()) Expect(parseErr.Line).Should(Equal(2)) - Expect(parseErr.Reason).Should(Equal("the `` tag is not closed")) + Expect(parseErr.Reason).Should(Equal( + "the opening `` tag is not closed; add `>` or `/>` before the code fence", + )) Expect(context.ReachedEOF()).Should(BeTrue()) Expect(context.GetResult()).Should(Equal([]string{ "preface", @@ -120,6 +122,56 @@ var _ = Describe("Parser states", func() { })) }) + It("should report a missing opening tag end before a code fence", func() { + config := configuration.NewConfiguration() + context := newStateContext( + "", + "", + "```", + "```", + ) + + err := parsing.EmbedInstruction.Accept(&context, config) + + Expect(err).Should(HaveOccurred()) + var parseErr parsing.InstructionParseError + Expect(errors.As(err, &parseErr)).Should(BeTrue()) + Expect(parseErr.Line).Should(Equal(1)) + Expect(parseErr.Reason).Should(Equal( + "the opening `` tag is not closed; add `>` or `/>` before the code fence", + )) + }) + + It("should report a mistyped non-self-closing instruction tag", func() { + config := configuration.NewConfiguration() + context := newStateContext( + "", + "", + ) + + err := parsing.EmbedInstruction.Accept(&context, config) + + Expect(err).Should(HaveOccurred()) + var parseErr parsing.InstructionParseError + Expect(errors.As(err, &parseErr)).Should(BeTrue()) + Expect(parseErr.Line).Should(Equal(1)) + Expect(parseErr.Reason).Should(Equal( + "expected `` instruction tag, got ``", + )) + }) + It("should render source and close the embedding fence when the end state is accepted", func() { sourceRoot := GinkgoT().TempDir() Expect(os.WriteFile( diff --git a/embedding/parsing/xml_parse.go b/embedding/parsing/xml_parse.go index d7ab6c8..44b8f97 100644 --- a/embedding/parsing/xml_parse.go +++ b/embedding/parsing/xml_parse.go @@ -42,6 +42,23 @@ type Item struct { Attrs []xml.Attr `xml:",any,attr"` } +// WrongInstructionTagError reports an XML tag that is not an embed-code instruction. +type WrongInstructionTagError struct { + // Expected is the supported instruction tag name. + Expected string + + // Actual is the tag name found in the parsed XML. + Actual string +} + +// Error describes the expected and actual instruction tag names. +func (e WrongInstructionTagError) Error() string { + return fmt.Sprintf("expected `<%s>` instruction tag, got `<%s>`", + e.Expected, + e.Actual, + ) +} + // FromXML parses an XML-like `` tag into an Instruction. // // The line can be self-closing: @@ -90,7 +107,10 @@ func ParseXMLLine(xmlLine string) (map[string]string, error) { if root.XMLName.Local != EmbeddingTag { return map[string]string{}, - fmt.Errorf("the provided line's header is not `%s`:\n%s", EmbeddingTag, xmlLine) + WrongInstructionTagError{ + Expected: EmbeddingTag, + Actual: root.XMLName.Local, + } } attributes := make(map[string]string) diff --git a/fragmentation/fragmentation_test.go b/fragmentation/fragmentation_test.go index 6e74599..6820c93 100644 --- a/fragmentation/fragmentation_test.go +++ b/fragmentation/fragmentation_test.go @@ -136,6 +136,26 @@ var _ = Describe("Fragmentation", func() { Expect(content).Should(Equal([]string{"class Example {}"})) }) + It("should report a non-UTF-8 source when no other code root resolves the file", func() { + sourceRoot := GinkgoT().TempDir() + fileName := "Example.java" + Expect(os.WriteFile(filepath.Join(sourceRoot, fileName), []byte{0xff}, 0600)). + To(Succeed()) + config.CodeRoots = _type.NamedPathList{_type.NamedPath{Path: sourceRoot}} + + content, err := resolver.ResolveContent( + fileName, + fragmentation.DefaultFragmentName, + config, + ) + + Expect(content).Should(BeNil()) + Expect(err).Should(MatchError(And( + ContainSubstring("Example.java"), + ContainSubstring("uses unsupported encoding; expected UTF-8"), + ))) + }) + It("should isolate cached source content between resolvers", func() { sourceRoot := GinkgoT().TempDir() fileName := "Example.java" diff --git a/fragmentation/resolver.go b/fragmentation/resolver.go index 8de4d9a..11914fb 100644 --- a/fragmentation/resolver.go +++ b/fragmentation/resolver.go @@ -55,6 +55,28 @@ type fragmentedFile struct { // absolutePath is a resolved absolute filesystem path. type absolutePath string +// unsupportedSourceEncodingError reports a source file that exists but cannot be decoded. +type unsupportedSourceEncodingError struct { + // sourcePath is the source file with unsupported encoding. + sourcePath absolutePath + + // err is the underlying encoding validation error. + err error +} + +// Error describes the source file and expected text encoding. +func (e unsupportedSourceEncodingError) Error() string { + return fmt.Sprintf( + "code file `%s` uses unsupported encoding; expected UTF-8", + logging.FileReference(string(e.sourcePath)), + ) +} + +// Unwrap returns the underlying encoding validation error. +func (e unsupportedSourceEncodingError) Unwrap() error { + return e.err +} + // Resolver resolves source files and caches fragmentations for one processing operation. type Resolver struct { // cache stores source fragmentations for this resolver instance. @@ -174,38 +196,68 @@ func (r *Resolver) resolveSource( config config.Configuration, ) (absolutePath, bool, error) { codeRootName, relativePath, named := splitNamedPath(codePath) + var unsupportedEncodingErr error + var unsupportedEncodingSource absolutePath for _, root := range config.CodeRoots { if named && strings.TrimSpace(root.Name) != codeRootName { continue } - source, err := sourceFromRoot(root, relativePath) - if err != nil { - return "", false, err - } - exists, err := files.IsFileExist(string(source)) - if err != nil { - return "", false, err - } - if !exists { - continue - } - - _, err = r.cachedSourceFragments(source) + source, found, err := r.resolveSourceInRoot(root, relativePath) var encodingError *unsupportedEncodingError if errors.As(err, &encodingError) { + if unsupportedEncodingErr == nil { + unsupportedEncodingErr = encodingError + unsupportedEncodingSource = source + } + continue } if err != nil { return "", false, err } + if !found { + continue + } return source, true, nil } + if unsupportedEncodingErr != nil { + return "", false, unsupportedSourceEncodingError{ + sourcePath: unsupportedEncodingSource, + err: unsupportedEncodingErr, + } + } + return "", false, nil } +// resolveSourceInRoot checks one configured source root for the requested relative path. +func (r *Resolver) resolveSourceInRoot( + root _type.NamedPath, + relativePath string, +) (absolutePath, bool, error) { + source, err := sourceFromRoot(root, relativePath) + if err != nil { + return "", false, err + } + exists, err := files.IsFileExist(string(source)) + if err != nil { + return "", false, err + } + if !exists { + return "", false, nil + } + + _, err = r.cachedSourceFragments(source) + if err != nil { + return source, false, err + } + + return source, true, nil +} + // splitNamedPath separates a named-code-root prefix from a code path. func splitNamedPath(codePath string) (string, string, bool) { normalizedPath := filepath.ToSlash(filepath.Clean(codePath))