fix(parser): align Go and JS parsers with capture grammar spec
Kind prefixes now follow the canonical grammar: `-` for todo, `@time` for event, `!time` for reminder. Removed `*`/`◇`/`▸` as capture aliases (display-layer only). Added `\` escape prefix, `?` query mode, `!pin` flag extraction, `##word` hash escape, and tag lowercasing. Both parsers produce identical results.
This commit is contained in:
+148
-56
@@ -13,7 +13,10 @@ type Result struct {
|
||||
Description *string
|
||||
TimeAnchor *string
|
||||
Tags []string
|
||||
FilterTags []string
|
||||
CardSuffix *string
|
||||
Pin bool
|
||||
Query bool
|
||||
}
|
||||
|
||||
var validCardTypes = map[string]string{
|
||||
@@ -39,26 +42,70 @@ func Parse(input string) (*Result, error) {
|
||||
|
||||
remaining := input
|
||||
|
||||
if sp := strings.IndexByte(remaining, ' '); sp >= 0 {
|
||||
switch remaining[:sp] {
|
||||
case "-", "▸":
|
||||
r.Glyph = "todo"
|
||||
remaining = strings.TrimSpace(remaining[sp+1:])
|
||||
case "*", "◇":
|
||||
r.Glyph = "event"
|
||||
remaining = strings.TrimSpace(remaining[sp+1:])
|
||||
// Step 1: Escape check — `\` prefix → thought, no prefix detection
|
||||
if strings.HasPrefix(remaining, `\`) {
|
||||
remaining = remaining[1:]
|
||||
r.Glyph = "note"
|
||||
clean, err := extractModifiers(r, remaining, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
switch remaining {
|
||||
case "-", "▸":
|
||||
r.Glyph = "todo"
|
||||
remaining = ""
|
||||
case "*", "◇":
|
||||
r.Body = clean
|
||||
if r.Body == "" && r.Title == nil {
|
||||
return nil, fmt.Errorf("empty body after extracting modifiers")
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Step 2: Query check — `?` prefix → search mode
|
||||
if strings.HasPrefix(remaining, "?") {
|
||||
remaining = strings.TrimSpace(remaining[1:])
|
||||
r.Query = true
|
||||
r.Glyph = ""
|
||||
tokens := strings.Fields(remaining)
|
||||
var bodyParts []string
|
||||
for _, tok := range tokens {
|
||||
if strings.HasPrefix(tok, "#") && len(tok) > 1 && !strings.HasPrefix(tok, "##") {
|
||||
tag := strings.ToLower(tok[1:])
|
||||
r.FilterTags = append(r.FilterTags, tag)
|
||||
} else {
|
||||
bodyParts = append(bodyParts, tok)
|
||||
}
|
||||
}
|
||||
r.Body = strings.Join(bodyParts, " ")
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Step 3: Kind prefix — `-`, `@time`, `!time`
|
||||
// `@` and `!` are kind prefixes ONLY if followed by a valid time token.
|
||||
// Otherwise the input is treated as a plain note.
|
||||
if strings.HasPrefix(remaining, "- ") {
|
||||
r.Glyph = "todo"
|
||||
remaining = strings.TrimSpace(remaining[2:])
|
||||
} else if remaining == "-" {
|
||||
r.Glyph = "todo"
|
||||
remaining = ""
|
||||
} else if strings.HasPrefix(remaining, "@") {
|
||||
if rest, ok := tryPrefixTime(r, remaining[1:]); ok {
|
||||
r.Glyph = "event"
|
||||
remaining = ""
|
||||
remaining = rest
|
||||
}
|
||||
} else if strings.HasPrefix(remaining, "!") {
|
||||
afterBang := remaining[1:]
|
||||
// `!pin` is a flag, not a reminder prefix
|
||||
firstWord := ""
|
||||
if fields := strings.Fields(afterBang); len(fields) > 0 {
|
||||
firstWord = fields[0]
|
||||
}
|
||||
if !strings.EqualFold(firstWord, "pin") {
|
||||
if rest, ok := tryPrefixTime(r, afterBang); ok {
|
||||
r.Glyph = "reminder"
|
||||
remaining = rest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Steps 4-5: Title and description extraction
|
||||
var titleRaw, descRaw string
|
||||
hasTitle := false
|
||||
|
||||
@@ -106,46 +153,9 @@ func Parse(input string) (*Result, error) {
|
||||
}
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
extract := func(text string) (string, error) {
|
||||
tokens := strings.Fields(text)
|
||||
var parts []string
|
||||
for _, tok := range tokens {
|
||||
switch {
|
||||
case strings.HasPrefix(tok, "@") && len(tok) > 1:
|
||||
timeStr := tok[1:]
|
||||
if err := validateTime(timeStr); err != nil {
|
||||
return "", fmt.Errorf("invalid time %q: %w", timeStr, err)
|
||||
}
|
||||
if r.TimeAnchor != nil {
|
||||
return "", fmt.Errorf("multiple time anchors")
|
||||
}
|
||||
r.TimeAnchor = &timeStr
|
||||
case strings.HasPrefix(tok, "#") && len(tok) > 1:
|
||||
tag := tok[1:]
|
||||
if !seen[tag] {
|
||||
r.Tags = append(r.Tags, tag)
|
||||
seen[tag] = true
|
||||
}
|
||||
case strings.HasPrefix(tok, "^") && len(tok) > 1:
|
||||
suffix := tok[1:]
|
||||
cardType, ok := validCardTypes[suffix]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("invalid card type %q", suffix)
|
||||
}
|
||||
if r.CardSuffix != nil {
|
||||
return "", fmt.Errorf("multiple card suffixes")
|
||||
}
|
||||
r.CardSuffix = &cardType
|
||||
default:
|
||||
parts = append(parts, tok)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " "), nil
|
||||
}
|
||||
|
||||
// Steps 6-8: Extract flags, tags, time, card suffix from title/desc/body
|
||||
if hasTitle {
|
||||
clean, err := extract(titleRaw)
|
||||
clean, err := extractModifiers(r, titleRaw, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -154,7 +164,7 @@ func Parse(input string) (*Result, error) {
|
||||
}
|
||||
}
|
||||
if descRaw != "" {
|
||||
clean, err := extract(descRaw)
|
||||
clean, err := extractModifiers(r, descRaw, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -163,7 +173,7 @@ func Parse(input string) (*Result, error) {
|
||||
}
|
||||
}
|
||||
|
||||
clean, err := extract(remaining)
|
||||
clean, err := extractModifiers(r, remaining, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -176,6 +186,88 @@ func Parse(input string) (*Result, error) {
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// tryPrefixTime attempts to extract a time token from the start of text.
|
||||
// Returns (remaining text, true) on success, or ("", false) if no valid time found.
|
||||
func tryPrefixTime(r *Result, text string) (string, bool) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return "", false
|
||||
}
|
||||
sp := strings.IndexByte(text, ' ')
|
||||
var timeStr, rest string
|
||||
if sp >= 0 {
|
||||
timeStr = text[:sp]
|
||||
rest = strings.TrimSpace(text[sp+1:])
|
||||
} else {
|
||||
timeStr = text
|
||||
rest = ""
|
||||
}
|
||||
if validateTime(timeStr) != nil {
|
||||
return "", false
|
||||
}
|
||||
r.TimeAnchor = &timeStr
|
||||
return rest, true
|
||||
}
|
||||
|
||||
// extractModifiers extracts tags, flags, time anchors, and card suffixes from text.
|
||||
// handleFlags controls whether !pin is extracted (true for body, false for title/desc in some contexts).
|
||||
func extractModifiers(r *Result, text string, handleFlags bool) (string, error) {
|
||||
tokens := strings.Fields(text)
|
||||
var parts []string
|
||||
seen := map[string]bool{}
|
||||
for _, t := range r.Tags {
|
||||
seen[strings.ToLower(t)] = true
|
||||
}
|
||||
|
||||
for _, tok := range tokens {
|
||||
switch {
|
||||
// !pin flag
|
||||
case handleFlags && strings.EqualFold(tok, "!pin"):
|
||||
r.Pin = true
|
||||
|
||||
// ##word escape → literal #word in body
|
||||
case strings.HasPrefix(tok, "##") && len(tok) > 2:
|
||||
parts = append(parts, "#"+tok[2:])
|
||||
|
||||
// @time inline time anchor (for todos: sets due_at)
|
||||
// If not valid time format, keep as body text
|
||||
case strings.HasPrefix(tok, "@") && len(tok) > 1:
|
||||
timeStr := tok[1:]
|
||||
if validateTime(timeStr) != nil {
|
||||
parts = append(parts, tok)
|
||||
} else if r.TimeAnchor != nil {
|
||||
return "", fmt.Errorf("multiple time anchors")
|
||||
} else {
|
||||
r.TimeAnchor = &timeStr
|
||||
}
|
||||
|
||||
// #tag extraction (lowercased, deduplicated)
|
||||
case strings.HasPrefix(tok, "#") && len(tok) > 1:
|
||||
tag := strings.ToLower(tok[1:])
|
||||
if !seen[tag] {
|
||||
r.Tags = append(r.Tags, tag)
|
||||
seen[tag] = true
|
||||
}
|
||||
|
||||
// ^card suffix (kept for now, spec says remove later)
|
||||
case strings.HasPrefix(tok, "^") && len(tok) > 1:
|
||||
suffix := tok[1:]
|
||||
cardType, ok := validCardTypes[suffix]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("invalid card type %q", suffix)
|
||||
}
|
||||
if r.CardSuffix != nil {
|
||||
return "", fmt.Errorf("multiple card suffixes")
|
||||
}
|
||||
r.CardSuffix = &cardType
|
||||
|
||||
default:
|
||||
parts = append(parts, tok)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " "), nil
|
||||
}
|
||||
|
||||
func validateTime(s string) error {
|
||||
parts := strings.SplitN(s, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
|
||||
Reference in New Issue
Block a user