diff --git a/pkg/codingcontext/markdown/markdown.go b/pkg/codingcontext/markdown/markdown.go index f9c2a7c..c82c32e 100644 --- a/pkg/codingcontext/markdown/markdown.go +++ b/pkg/codingcontext/markdown/markdown.go @@ -55,12 +55,13 @@ func ParseMarkdownFile[T any](path string, frontMatter *T) (Markdown[T], error) case 1: // Scanning frontmatter if line == "---" { state = 2 // End of frontmatter, start scanning content + // From here on, just copy everything as-is to content } else { if _, err := frontMatterBytes.WriteString(line + "\n"); err != nil { return Markdown[T]{}, fmt.Errorf("failed to write frontmatter: %w", err) } } - case 2: // Scanning content + case 2: // Scanning content - copy everything as-is if _, err := content.WriteString(line + "\n"); err != nil { return Markdown[T]{}, fmt.Errorf("failed to write content: %w", err) } diff --git a/pkg/codingcontext/markdown/markdown_test.go b/pkg/codingcontext/markdown/markdown_test.go index 86f4db1..1bbff10 100644 --- a/pkg/codingcontext/markdown/markdown_test.go +++ b/pkg/codingcontext/markdown/markdown_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/kitproj/coding-context-cli/pkg/codingcontext/taskparser" ) func TestParseMarkdownFile(t *testing.T) { @@ -272,3 +274,99 @@ This task has no frontmatter. }) } } + +func TestParseMarkdownFile_MultipleNewlinesAfterFrontmatter(t *testing.T) { + // This test verifies that multiple newlines after the frontmatter + // closing delimiter are handled correctly. + // The parser should: + // 1. Preserve multiple newlines between frontmatter and content + // 2. Strip a single newline (treating it as just a separator) + // 3. Allow the task parser to successfully parse content that starts with newlines + tests := []struct { + name string + content string + wantContent string + }{ + { + name: "multiple newlines after frontmatter", + content: `--- +{} +--- + +Start of context +`, + wantContent: "\nStart of context\n", // Content copied as-is after frontmatter + }, + { + name: "single newline after frontmatter (baseline)", + content: `--- +{} +--- +Start of context +`, + wantContent: "Start of context\n", // Content copied as-is after frontmatter (newline after --- is preserved) + }, + { + name: "three newlines after frontmatter", + content: `--- +{} +--- + + +Start of context +`, + wantContent: "\n\nStart of context\n", // Content copied as-is after frontmatter + }, + { + name: "mixed whitespace after frontmatter", + content: `--- +{} +--- + + + +Start of context +`, + wantContent: " \n\t \n\nStart of context\n", // Content copied as-is, preserving whitespace (newline after --- is preserved) + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a temporary file + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "test.md") + if err := os.WriteFile(tmpFile, []byte(tt.content), 0o644); err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + + // Parse the file + var frontmatter BaseFrontMatter + md, err := ParseMarkdownFile(tmpFile, &frontmatter) + if err != nil { + t.Fatalf("ParseMarkdownFile() error = %v", err) + } + + // Check content + if md.Content != tt.wantContent { + t.Errorf("ParseMarkdownFile() content = %q, want %q", md.Content, tt.wantContent) + } + + // Verify that the content can be parsed as a task + // This is the actual use case - content is parsed as a task after frontmatter extraction + task, err := taskparser.ParseTask(md.Content) + if err != nil { + t.Fatalf("ParseTask() failed: %v, content = %q", err, md.Content) + } + if len(task) == 0 && strings.TrimSpace(md.Content) != "" { + t.Errorf("ParseTask() returned empty task for non-empty content: %q", md.Content) + } + // Verify that the parsed task content matches the original exactly + // The parser preserves all content including leading newlines + taskContent := task.String() + if taskContent != md.Content { + t.Errorf("ParseTask() then String() = %q, want %q", taskContent, md.Content) + } + }) + } +} diff --git a/pkg/codingcontext/taskparser/grammar.go b/pkg/codingcontext/taskparser/grammar.go index c378e85..a1c43e9 100644 --- a/pkg/codingcontext/taskparser/grammar.go +++ b/pkg/codingcontext/taskparser/grammar.go @@ -38,7 +38,8 @@ type Argument struct { // It can span multiple lines, consuming line content and newlines // But it will stop before a newline that's followed by a slash (potential command) type Text struct { - Lines []TextLine `parser:"@@+"` + LeadingNewlines []string `parser:"@Newline*"` // Leading newlines before any content (empty lines at the start) + Lines []TextLine `parser:"@@+"` // At least one line with actual content } // TextLine is a single line of text content (not starting with a slash) diff --git a/pkg/codingcontext/taskparser/taskparser.go b/pkg/codingcontext/taskparser/taskparser.go index 14bbcca..1570840 100644 --- a/pkg/codingcontext/taskparser/taskparser.go +++ b/pkg/codingcontext/taskparser/taskparser.go @@ -160,6 +160,11 @@ func (s *SlashCommand) Params() Params { // Content returns the text content with all lines concatenated func (t *Text) Content() string { var sb strings.Builder + // Write leading newlines first + for _, nl := range t.LeadingNewlines { + sb.WriteString(nl) + } + // Then write all the lines for _, line := range t.Lines { for _, tok := range line.NonSlashStart { sb.WriteString(tok)