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, ""+EmbeddingTag+">") {
- 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 `%s>` 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, ""+EmbeddingTag):
+ return false, true
+ case strings.HasPrefix(remaining, "<"+EmbeddingTag):
+ return false, true
+ default:
+ return false, false
+ }
+ })
+}
+
+// instructionClosed reports whether the instruction has a self-closing or closing tag.
+func instructionClosed(instructionBody []string) bool {
+ return scanInstructionBody(instructionBody, func(remaining string) (bool, bool) {
+ switch {
+ case strings.HasPrefix(remaining, "/>"):
+ return true, true
+ case strings.HasPrefix(remaining, ""+EmbeddingTag+">"):
+ 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))