Files
Ilker ManapandClaude Opus 5 c174e46902
CI / test (push) Successful in 1m35s
Read the filename from a path comment inside the block
The commonest convention of all, and the one the extractor missed. Asked
for a two-file Go server, qwen2.5-coder-3b opened a plain ```go fence and
wrote "// cmd/server/main.go" as the first line of the file — not on the
fence, not above it. Both files downloaded as snippet-1.go and
snippet-2.go, which is the feature failing at the first real use of it.

Found by running it rather than by reading it: the unit tests covered the
conventions I had thought of, and passed throughout.

Only the first line or two, only when the line is a comment holding one
token that looks like a path. "// this reads config.json at startup" is a
sentence, "// Package main implements the server." is a doc comment, and a
path comment further down the file is just a comment. The markers cover the
languages people actually ask for, including block comments closed on the
same line.

The comment stays in the body. People write a path at the top of a file
anyway, and editing model output to make a filename look tidier is a
surprise nobody asked for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:12:00 +02:00

324 lines
9.2 KiB
Go

package web
import (
"fmt"
"path"
"strings"
)
// CodeFile is one fenced block from a reply, with the path it should be
// written to.
type CodeFile struct {
Path string
Body string
}
// maxCodeFiles and maxCodeBytes bound what one reply can turn into.
//
// The reply is already bounded by the token cap, so these are not the real
// defence; they are there so a change to that cap, or a model that emits
// something pathological, cannot turn a download into an archive nobody asked
// for.
const (
maxCodeFiles = 64
maxCodeBytes = 4 << 20
)
// ExtractCode pulls fenced code blocks out of a model reply and decides where
// each one belongs.
//
// The filename is whatever the model said, and models say it in several ways:
// on the fence itself (```go:main.go, ```main.go), or on the line above the
// fence as a heading or a comment. All of them are guesses, and a block whose
// name cannot be worked out still comes back — under a generated name, because
// dropping someone's code because it was unlabelled is worse than naming it
// badly.
func ExtractCode(reply string) []CodeFile {
lines := strings.Split(reply, "\n")
var out []CodeFile
used := map[string]int{}
for i := 0; i < len(lines); i++ {
fence, info, ok := openingFence(lines[i])
if !ok {
continue
}
var body []string
j := i + 1
for ; j < len(lines); j++ {
if isClosingFence(lines[j], fence) {
break
}
body = append(body, lines[j])
}
lang, named := splitInfo(info)
if named == "" {
named = nameFromNearbyLine(lines, i)
}
if named == "" {
named = nameFromLeadingComment(body)
}
p := safePath(named)
if p == "" {
p = fmt.Sprintf("snippet-%d%s", len(out)+1, extensionFor(lang))
}
// Two blocks claiming one path would silently lose the first. Models
// do this when they show a file, then show it again with one line
// changed.
if n := used[p]; n > 0 {
ext := path.Ext(p)
p = fmt.Sprintf("%s-%d%s", strings.TrimSuffix(p, ext), n+1, ext)
}
used[p]++
out = append(out, CodeFile{Path: p, Body: strings.Join(body, "\n") + "\n"})
if len(out) >= maxCodeFiles {
break
}
i = j
}
total := 0
for i := range out {
total += len(out[i].Body)
if total > maxCodeBytes {
return out[:i]
}
}
return out
}
// openingFence recognises ``` and ~~~ fences of three characters or more,
// returning the fence itself so the matching close can be found.
func openingFence(line string) (fence, info string, ok bool) {
t := strings.TrimSpace(line)
for _, c := range []string{"`", "~"} {
if !strings.HasPrefix(t, strings.Repeat(c, 3)) {
continue
}
n := 0
for n < len(t) && string(t[n]) == c {
n++
}
info = strings.TrimSpace(t[n:])
// An info string containing a backtick is not a fence at all; that is
// inline code with backticks in it.
if c == "`" && strings.Contains(info, "`") {
return "", "", false
}
return t[:n], info, true
}
return "", "", false
}
func isClosingFence(line, fence string) bool {
t := strings.TrimSpace(line)
return strings.HasPrefix(t, fence) && strings.TrimRight(t, string(fence[0])) == ""
}
// splitInfo separates the language from a filename on the fence line.
//
// Handles ```go:path/main.go, ```go path/main.go, ```path/main.go and bare
// ```go. A token is treated as a name rather than a language when it looks
// like one: it has a dot or a slash in it.
func splitInfo(info string) (lang, name string) {
if info == "" {
return "", ""
}
if l, n, found := strings.Cut(info, ":"); found {
return strings.TrimSpace(l), strings.TrimSpace(n)
}
fields := strings.Fields(info)
if len(fields) == 0 {
return "", ""
}
first := fields[0]
// Strip the attribute forms some tools use: title="x", file=x.
for _, f := range fields[1:] {
for _, k := range []string{"title=", "file=", "filename=", "name="} {
if v, ok := strings.CutPrefix(f, k); ok {
return first, strings.Trim(v, `"'`)
}
}
}
if looksLikePath(first) {
return "", first
}
if len(fields) > 1 && looksLikePath(fields[1]) {
return first, fields[1]
}
return first, ""
}
func looksLikePath(s string) bool {
if strings.Contains(s, "/") || strings.Contains(s, ".") {
return true
}
// Files whose whole name is their type. ```Dockerfile is a language tag to
// a syntax highlighter and a filename to everyone else, and the reader
// wants a file called Dockerfile either way.
switch s {
case "Dockerfile", "Makefile", "Jenkinsfile", "Vagrantfile", "Procfile", "Gemfile", "Rakefile", "CMakeLists":
return true
}
return false
}
// nameFromNearbyLine looks just above the fence for a filename.
//
// Models very often write the path as a short line of its own — as a heading,
// bold, in backticks, or as a comment — and then open the fence. Only the two
// lines immediately above are considered, because anything further away is
// prose that happens to mention a file.
func nameFromNearbyLine(lines []string, fence int) string {
for k := fence - 1; k >= 0 && k >= fence-2; k-- {
t := strings.TrimSpace(lines[k])
if t == "" {
continue
}
t = strings.Trim(t, "*_#:` ")
t = strings.TrimPrefix(t, "//")
t = strings.TrimPrefix(t, "#")
for _, k := range []string{"File:", "file:", "FILE:", "Filename:", "filename:"} {
t = strings.TrimSpace(strings.TrimPrefix(t, k))
}
t = strings.TrimSpace(t)
// One token that looks like a path. A sentence mentioning main.go is
// not a filename line.
if fields := strings.Fields(t); len(fields) == 1 && looksLikePath(fields[0]) {
return fields[0]
}
return ""
}
return ""
}
// nameFromLeadingComment reads a path out of the first line or two of a block.
//
// The commonest convention of all, and the one this missed at first: the model
// opens a plain ```go fence and writes "// cmd/server/main.go" as the first
// line of the file. Asked for a two-file Go server, that is exactly what came
// back, and both files landed in the archive as snippet-1.go and snippet-2.go.
//
// The comment is left in the body. A path comment at the top of a file is
// something people write anyway, and editing model output to make a filename
// look tidier is not worth the surprise.
func nameFromLeadingComment(body []string) string {
for n, line := range body {
if n > 1 {
return ""
}
t := strings.TrimSpace(line)
if t == "" {
continue
}
// The comment markers that start a line across the languages people
// ask for. Block comments count only when closed on the same line.
for _, open := range []string{"//", "#", "--", ";", "%", "<!--", "/*"} {
rest, ok := strings.CutPrefix(t, open)
if !ok {
continue
}
rest = strings.TrimSuffix(strings.TrimSuffix(strings.TrimSpace(rest), "-->"), "*/")
rest = strings.TrimSpace(rest)
for _, k := range []string{"File:", "file:", "FILE:", "Filename:", "filename:"} {
rest = strings.TrimSpace(strings.TrimPrefix(rest, k))
}
// One token that looks like a path, and nothing else on the line.
// "// this reads config.json at startup" is a sentence.
if fields := strings.Fields(rest); len(fields) == 1 && looksLikePath(fields[0]) {
return fields[0]
}
return ""
}
// A line of actual code: there is no path comment.
return ""
}
return ""
}
// safePath turns a model-supplied name into something safe to write inside an
// archive, or "" when it cannot.
//
// This is the part that matters. The name comes from a language model and goes
// into a zip that someone will extract, so "../../.ssh/authorized_keys" and
// "/etc/passwd" and "C:\\Windows\\..." are all things to expect rather than
// things to hope against. Everything is forced to a relative path under the
// archive root, and anything that still escapes is rejected outright rather
// than repaired into something surprising.
func safePath(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
// Windows separators and drive letters, before anything else looks at it.
name = strings.ReplaceAll(name, `\`, "/")
if len(name) > 1 && name[1] == ':' {
name = name[2:]
}
name = strings.TrimPrefix(name, "/")
cleaned := path.Clean(name)
if cleaned == "." || cleaned == "/" {
return ""
}
if strings.HasPrefix(cleaned, "../") || cleaned == ".." || strings.HasPrefix(cleaned, "/") {
return ""
}
for _, seg := range strings.Split(cleaned, "/") {
if seg == "" || seg == "." || seg == ".." {
return ""
}
// A NUL or a newline in a path is not a filename anyone meant.
if strings.ContainsAny(seg, "\x00\n\r") {
return ""
}
if len(seg) > 128 {
return ""
}
}
if len(cleaned) > 512 || strings.Count(cleaned, "/") > 16 {
return ""
}
return cleaned
}
// extensionFor gives an unnamed block a plausible extension from its language.
func extensionFor(lang string) string {
switch strings.ToLower(lang) {
case "go":
return ".go"
case "python", "py":
return ".py"
case "javascript", "js":
return ".js"
case "typescript", "ts":
return ".ts"
case "rust", "rs":
return ".rs"
case "c":
return ".c"
case "cpp", "c++":
return ".cpp"
case "java":
return ".java"
case "sh", "bash", "shell":
return ".sh"
case "sql":
return ".sql"
case "yaml", "yml":
return ".yaml"
case "json":
return ".json"
case "html":
return ".html"
case "css":
return ".css"
case "":
return ".txt"
default:
return "." + strings.ToLower(lang)
}
}