Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions pkg/codingcontext/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ func (cc *Context) makeMarkdownWalkFunc(visitor markdownVisitor) filepath.WalkFu
}

var fm markdown.BaseFrontMatter
if _, parseErr := markdown.ParseMarkdownFile(path, &fm); parseErr != nil {
if _, parseErr := markdown.ParseMarkdownFileWithLogger(path, &fm, cc.logger); parseErr != nil {
if cc.lintCollector != nil {
var pe *markdown.ParseError
if errors.As(parseErr, &pe) {
Expand Down Expand Up @@ -386,7 +386,7 @@ func (cc *Context) findTask(taskName string) error {
func (cc *Context) loadTask(path, taskName string) error {
var frontMatter markdown.TaskFrontMatter

md, err := markdown.ParseMarkdownFile(path, &frontMatter)
md, err := markdown.ParseMarkdownFileWithLogger(path, &frontMatter, cc.logger)
if err != nil {
return fmt.Errorf("failed to parse task file %s: %w", path, err)
}
Expand Down Expand Up @@ -428,7 +428,7 @@ func (cc *Context) loadTask(path, taskName string) error {

var parseErr error

task, parseErr = taskparser.ParseTask(taskContent)
task, parseErr = taskparser.ParseTaskWithLogger(taskContent, cc.logger)
if parseErr != nil {
return fmt.Errorf("failed to parse task content in file %s: %w", path, parseErr)
}
Expand Down Expand Up @@ -549,7 +549,7 @@ func (cc *Context) findCommand(commandName string, params taskparser.Params) (st

var frontMatter markdown.CommandFrontMatter

md, err := markdown.ParseMarkdownFile(path, &frontMatter)
md, err := markdown.ParseMarkdownFileWithLogger(path, &frontMatter, cc.logger)
if err != nil {
return fmt.Errorf("failed to parse command file %s: %w", path, err)
}
Expand Down Expand Up @@ -816,7 +816,7 @@ func (cc *Context) findExecuteRuleFiles(ctx context.Context) error {
err := cc.visitMarkdownFiles(namespacedRulePaths, func(path string, baseFm *markdown.BaseFrontMatter) error {
var frontmatter markdown.RuleFrontMatter

md, err := markdown.ParseMarkdownFile(path, &frontmatter)
md, err := markdown.ParseMarkdownFileWithLogger(path, &frontmatter, cc.logger)
if err != nil {
return fmt.Errorf("failed to parse markdown file %s: %w", path, err)
}
Expand Down Expand Up @@ -1042,7 +1042,7 @@ func (cc *Context) loadSkillEntry(skillFile string, lenient bool) error {

var frontmatter markdown.SkillFrontMatter

if _, err := markdown.ParseMarkdownFile(skillFile, &frontmatter); err != nil {
if _, err := markdown.ParseMarkdownFileWithLogger(skillFile, &frontmatter, cc.logger); err != nil {
if lenient {
cc.logger.Warn("skipping skill file: failed to parse YAML frontmatter", "path", skillFile, "error", err)

Expand Down
14 changes: 12 additions & 2 deletions pkg/codingcontext/markdown/markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package markdown
import (
"bytes"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -68,7 +69,16 @@ type RuleMarkdown = Markdown[RuleFrontMatter]

// ParseMarkdownFile parses a markdown file into frontmatter and content using goldmark.
// Errors include file path and, where available, line and column position.
// Uses slog.Default() for taskparser WARN logs; for a custom logger use
// ParseMarkdownFileWithLogger.
func ParseMarkdownFile[T any](path string, frontMatter *T) (Markdown[T], error) {
return ParseMarkdownFileWithLogger(path, frontMatter, nil)
}

// ParseMarkdownFileWithLogger is like ParseMarkdownFile but routes taskparser
// best-effort fallback WARN logs through the given logger. A nil logger
// resolves to slog.Default() at parse time (not capture time).
func ParseMarkdownFileWithLogger[T any](path string, frontMatter *T, logger *slog.Logger) (Markdown[T], error) {
cleanPath := filepath.Clean(path)

source, err := os.ReadFile(cleanPath)
Expand All @@ -77,9 +87,9 @@ func ParseMarkdownFile[T any](path string, frontMatter *T) (Markdown[T], error)
}

// Parse with goldmark+meta+taskparser in a single pass: meta extracts frontmatter,
// taskparser.Extension captures task structure (slash commands) from the body.
// the taskparser extension captures task structure (slash commands) from the body.
pctx := parser.NewContext()
doc := goldmark.New(goldmark.WithExtensions(meta.Meta, taskparser.Extension)).Parser().
doc := goldmark.New(goldmark.WithExtensions(meta.Meta, taskparser.NewExtension(logger))).Parser().
Parse(text.NewReader(source), parser.WithContext(pctx))

// Get frontmatter map from goldmark-meta (parsed during goldmark parse).
Expand Down
90 changes: 75 additions & 15 deletions pkg/codingcontext/taskparser/markdownparser.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package taskparser
import (
"bytes"
"fmt"
"log/slog"
"sort"
"strings"

Expand Down Expand Up @@ -95,14 +96,14 @@ func collectCodeRanges(doc ast.Node) ([]codeRange, error) {
// newline characters. This prevents the next text segment from starting with a bare
// newline immediately before a slash, which would cause the grammar parser to fail
// (it cannot parse a Text block that has only leading newlines before a slash).
func splitAndParse(content string, codeRanges []codeRange) (Task, error) {
func splitAndParse(content string, codeRanges []codeRange, logger *slog.Logger) (Task, error) {
var allBlocks []Block

pos := 0

for i, cr := range codeRanges {
if pos < cr.start {
blocks, err := parseGrammar(content[pos:cr.start])
blocks, err := parseGrammar(content[pos:cr.start], logger)
if err != nil {
return nil, err
}
Expand All @@ -127,7 +128,7 @@ func splitAndParse(content string, codeRanges []codeRange) (Task, error) {
}

if pos < len(content) {
blocks, err := parseGrammar(content[pos:])
blocks, err := parseGrammar(content[pos:], logger)
if err != nil {
return nil, err
}
Expand All @@ -149,17 +150,63 @@ func trailingNewlineEnd(content string, pos int) int {

// parseGrammar runs the participle grammar parser on a plain text segment.
// It returns nil blocks (not an error) for whitespace-only input.
func parseGrammar(content string) ([]Block, error) {
//
// On parser failure (e.g. a line starting with `/` but lacking a valid term:
// `//`, lone `/`, `/=foo`), falls back to a best-effort line-by-line parse so
// one malformed line cannot discard the entire task.
func parseGrammar(content string, logger *slog.Logger) ([]Block, error) {
if strings.TrimSpace(content) == "" {
return nil, nil
}

input, err := parser().ParseString("", content)
if err != nil {
return nil, fmt.Errorf("failed to parse task: %w", err)
if err == nil {
return input.Blocks, nil
}

return parseGrammarBestEffort(content, logger), nil
}

// loggerOrDefault returns logger, or slog.Default() if logger is nil. Resolving
// at use time (rather than capturing at construction) ensures slog.SetDefault
// changes take effect for callers that pass nil.
func loggerOrDefault(logger *slog.Logger) *slog.Logger {
if logger == nil {
return slog.Default()
}

return input.Blocks, nil
return logger
}

// parseGrammarBestEffort parses content line-by-line, returning a raw text
// block for any line the grammar rejects. Called only after the whole-segment
// parse has already failed.
func parseGrammarBestEffort(content string, logger *slog.Logger) []Block {
var blocks []Block

for _, line := range strings.SplitAfter(content, "\n") {
if line == "" {
continue // strings.SplitAfter emits a trailing "" when input ends with "\n"
}

if strings.TrimSpace(line) == "" {
blocks = append(blocks, rawTextBlock(line))
continue
}

input, err := parser().ParseString("", line)
if err != nil {
loggerOrDefault(logger).Warn("taskparser: malformed slash command, treating line as text",
"line", strings.TrimRight(line, "\r\n"), "error", err)
blocks = append(blocks, rawTextBlock(line))

continue
}

blocks = append(blocks, input.Blocks...)
}

return blocks
}

// rawTextBlock wraps a raw string as a Text block without any slash command parsing.
Expand All @@ -176,6 +223,8 @@ func rawTextBlock(content string) Block {

// Extension is a goldmark extension that parses task structure during the markdown parse.
// Include it in a goldmark instance and use GetTask to retrieve the parsed Task after parsing.
// Uses slog.Default() for WARN logs from the best-effort fallback; for a custom logger
// use NewExtension.
//
// Example:
//
Expand All @@ -185,14 +234,23 @@ func rawTextBlock(content string) Block {
// task, err := taskparser.GetTask(pctx)
//
//nolint:gochecknoglobals // goldmark.WithExtensions expects a package-level extender
var Extension goldmark.Extender = &taskExtension{}
var Extension goldmark.Extender = NewExtension(nil)

type taskExtension struct{}
// NewExtension returns a goldmark extension that parses task structure and routes
// best-effort fallback WARN logs through the given logger. A nil logger resolves to
// slog.Default() at parse time (not capture time), so SetDefault changes take effect.
func NewExtension(logger *slog.Logger) goldmark.Extender {
return &taskExtension{logger: logger}
}

type taskExtension struct {
logger *slog.Logger // nil means use slog.Default() at parse time
}

func (e *taskExtension) Extend(m goldmark.Markdown) {
const taskTransformerPriority = 100
m.Parser().AddOptions(gparser.WithASTTransformers(
util.Prioritized(&taskTransformer{}, taskTransformerPriority),
util.Prioritized(&taskTransformer{logger: e.logger}, taskTransformerPriority),
))
}

Expand Down Expand Up @@ -225,7 +283,9 @@ func GetTask(pc gparser.Context) (Task, error) {
// taskTransformer implements parser.ASTTransformer. It runs after goldmark has built
// the document AST and extracts task structure (text vs. slash commands), skipping
// slash command detection inside code blocks, indented code, and HTML blocks.
type taskTransformer struct{}
type taskTransformer struct {
logger *slog.Logger
}

func (t *taskTransformer) Transform(node *ast.Document, reader text.Reader, pc gparser.Context) {
source := reader.Source()
Expand Down Expand Up @@ -265,17 +325,17 @@ func (t *taskTransformer) Transform(node *ast.Document, reader text.Reader, pc g
adjusted = append(adjusted, codeRange{adjStart, adjStop})
}

task, parseErr := splitAndParse(content, adjusted)
task, parseErr := splitAndParse(content, adjusted, t.logger)
pc.Set(contextKey, &taskParseResult{task: task, err: parseErr})
}

// parseMarkdownAware parses task content while skipping slash command detection inside
// code blocks (fenced code, indented code, HTML blocks) by running the Extension
// during a single goldmark parse pass.
func parseMarkdownAware(content string) (Task, error) {
// during a single goldmark parse pass. A nil logger falls back to slog.Default().
func parseMarkdownAware(content string, logger *slog.Logger) (Task, error) {
source := []byte(content)
pctx := gparser.NewContext()
goldmark.New(goldmark.WithExtensions(Extension)).Parser().
goldmark.New(goldmark.WithExtensions(NewExtension(logger))).Parser().
Parse(text.NewReader(source), gparser.WithContext(pctx))

return GetTask(pctx)
Expand Down
10 changes: 9 additions & 1 deletion pkg/codingcontext/taskparser/taskparser.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
package taskparser

import (
"log/slog"
"strings"
)

Expand Down Expand Up @@ -111,13 +112,20 @@ import (
// task, _ := ParseTask("Introduction text\n/fix-bug 123\nSome text after")
// // len(task) == 3 (text, command, text)
func ParseTask(text string) (Task, error) {
return ParseTaskWithLogger(text, nil)
}

// ParseTaskWithLogger is like ParseTask but routes best-effort fallback WARN
// logs through the given logger. A nil logger resolves to slog.Default() at
// parse time (not capture time), so SetDefault changes take effect.
func ParseTaskWithLogger(text string, logger *slog.Logger) (Task, error) {
// Handle empty or whitespace-only content gracefully
// TrimSpace returns empty string for whitespace-only input
if strings.TrimSpace(text) == "" {
return Task{}, nil
}

return parseMarkdownAware(text)
return parseMarkdownAware(text, logger)
}

// Params converts the slash command's arguments into a parameter map using ParseParams.
Expand Down
Loading
Loading