// Fenced code blocks: what an agent writes in Markdown, and the one place a // scanner can be pointed at with confidence. package acp import ( "strings" "codeberg.org/turbo-editors/turbo-core/syntax" ) // Block is a run of an entry's text that is either prose or code. // // An agent answers in Markdown, so a fenced block is a real thing in what it // sends and the one place a scanner can be pointed at with confidence. type Block struct { // Language is what the fence named, or LanguageNone for prose and for a // fence that named nothing. Language syntax.Language // Code says this block came out of a fence. Code bool // Text is the block's own text, without the fence markers. Text string } // SplitBlocks separates fenced code from the prose around it. // // Only the *fence* decides. A scanner is never guessed at from the shape of // the text: a highlighter that is wrong is worse than one that is quiet, which // is the rule every scanner in this library already follows. // // blocks := acp.SplitBlocks("here:\n```go\nx := 1\n```\n") // blocks[1].Language // "go" func SplitBlocks(text string) []Block { var blocks []Block var current []string inFence, language := false, syntax.LanguageNone flush := func(code bool, lang syntax.Language) { if len(current) == 0 { return } blocks = append(blocks, Block{Language: lang, Code: code, Text: strings.Join(current, "\n")}) current = nil } for _, line := range strings.Split(text, "\n") { if fence, named := fenceOf(line); fence { if inFence { flush(true, language) inFence, language = false, syntax.LanguageNone continue } flush(false, syntax.LanguageNone) inFence, language = true, named continue } current = append(current, line) } // A fence nobody closed is still code: an agent cut off mid-answer leaves // one, and drawing the rest as prose would change colour halfway down a // function for no reason a reader can see. flush(inFence, language) return blocks } // fenceOf reports whether a line opens or closes a fence, and which language // it named. func fenceOf(line string) (bool, syntax.Language) { trimmed := strings.TrimSpace(line) if !strings.HasPrefix(trimmed, "```") { return false, syntax.LanguageNone } name := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(trimmed, "```"))) if name == "" { return true, syntax.LanguageNone } return true, languageNamed(name) } // languageNamed returns the registered language a fence's tag means, or // LanguageNone when no scanner claims it. // // The tag is matched against what the editor actually knows rather than // against a list kept here, so an editor that registers Rust colours a ```rust // block with no change to this file. The few aliases below are the ones a // model reaches for by habit. func languageNamed(name string) syntax.Language { if alias, ok := fenceAliases[name]; ok { name = alias } wanted := syntax.Language(name) for _, known := range syntax.Registered() { if known == wanted { return known } } return syntax.LanguageNone } // fenceAliases maps the tags models write onto the names languages register // themselves under. var fenceAliases = map[string]string{ "golang": "go", "sh": "bash", "shell": "bash", "zsh": "bash", "console": "bash", "js": "javascript", "mjs": "javascript", "yml": "yaml", "md": "markdown", "docker": "dockerfile", "containerfile": "dockerfile", }