200 lines
6.9 KiB
Go
200 lines
6.9 KiB
Go
// Package readselector parses Oh-My-Pi (omp) style read paths of the form
|
|
// "path:<selector>" into the bare file path plus the parsed line range.
|
|
//
|
|
// OMP's read tool embeds a line/range/mode selector in the path argument
|
|
// (e.g. "foo.go:43-94", "foo.go:50+150", "foo.go:5-16,960-973", "foo.go:raw",
|
|
// "foo.go:2-4:raw"). Kandev opens files by stat'ing the path, so a selector
|
|
// left on the path makes the open fail ("Failed to open file … no such file"
|
|
// for workspace files, or "path traversal detected" for absolute external
|
|
// files whose stat'd "<path>:<range>" simply doesn't exist).
|
|
//
|
|
// Both the ACP normalizer (so new file links are stored clean and the line
|
|
// range is surfaced separately) and the file-open boundary (so already-persisted
|
|
// links and any normalization gap still open) call Split — a single source of
|
|
// truth shared by both.
|
|
package readselector
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Split splits "path:<selector>" into the bare file path plus the parsed line
|
|
// range. startLine is the first line referenced by the selector; lineCount is
|
|
// the contiguous span when a closed range is given ("N-M" or "N+K"), and 0 when
|
|
// the selector is open-ended ("N", "N-") or carries no line numbers ("raw").
|
|
// When no valid selector is present the path is returned unchanged with zero
|
|
// line info, so paths from non-omp agents (which never match the grammar) and
|
|
// ordinary filenames are unaffected.
|
|
func Split(raw string) (path string, startLine, lineCount int) {
|
|
// Only inspect colons in the final path segment so a directory component
|
|
// that legitimately contains a colon is never mistaken for a selector.
|
|
// Consider both '/' and '\\' so Windows paths — and their drive-letter
|
|
// colon, e.g. "C:\\dir\\file.go" — are not misread as a selector boundary.
|
|
lastSep := strings.LastIndexByte(raw, '/')
|
|
if b := strings.LastIndexByte(raw, '\\'); b > lastSep {
|
|
lastSep = b
|
|
}
|
|
rel := strings.IndexByte(raw[lastSep+1:], ':')
|
|
if rel < 0 {
|
|
return raw, 0, 0
|
|
}
|
|
colon := lastSep + 1 + rel
|
|
if lastSep < 0 && isWindowsDrivePrefix(raw, colon) {
|
|
return raw, 0, 0
|
|
}
|
|
base, suffix := raw[:colon], raw[colon+1:]
|
|
if base == "" || suffix == "" {
|
|
return raw, 0, 0
|
|
}
|
|
start, count, ok := parseReadSelector(suffix)
|
|
if !ok {
|
|
return raw, 0, 0
|
|
}
|
|
return base, start, count
|
|
}
|
|
|
|
// File is one file reference parsed out of a (possibly multi-file) read path.
|
|
type File struct {
|
|
Path string
|
|
StartLine int
|
|
LineCount int
|
|
}
|
|
|
|
// SplitFiles splits an omp read path that may reference several comma-joined
|
|
// files ("a.yaml:1-80,b.yaml:1-80") into one File per file, each with its own
|
|
// parsed line range. A comma segment that is purely a line-spec list ("960-973")
|
|
// or a mode keyword ("raw"/"conflicts") is treated as an additional range of the
|
|
// preceding file — so a single file's multi-range selector ("main.go:5-16,40-80")
|
|
// stays one File, matching Split (first range wins).
|
|
//
|
|
// Multiple files are only reported when every segment is a self-contained file
|
|
// reference — it carries a read selector or its basename has an extension.
|
|
// Otherwise it returns the single Split result, so single-file behavior is never
|
|
// disturbed: a comma inside a directory name ("a,b/foo.go") or inside a filename
|
|
// ("src/foo,bar.go:1-20") stays one File rather than splitting into phantoms.
|
|
func SplitFiles(raw string) []File {
|
|
parts := strings.Split(raw, ",")
|
|
files := make([]File, 0, len(parts))
|
|
multi := true
|
|
for i, part := range parts {
|
|
if i > 0 && isContinuationRange(part) {
|
|
continue
|
|
}
|
|
if !isStrongFile(part) {
|
|
multi = false
|
|
}
|
|
path, start, count := Split(part)
|
|
files = append(files, File{Path: path, StartLine: start, LineCount: count})
|
|
}
|
|
if multi && len(files) > 1 {
|
|
return files
|
|
}
|
|
path, start, count := Split(raw)
|
|
return []File{{Path: path, StartLine: start, LineCount: count}}
|
|
}
|
|
|
|
// isContinuationRange reports whether a comma segment is an extra range of the
|
|
// preceding file rather than a new file: a bare line-spec list or a mode keyword.
|
|
func isContinuationRange(part string) bool {
|
|
if part == "raw" || part == "conflicts" {
|
|
return true
|
|
}
|
|
_, _, ok := parseLineSpecList(part)
|
|
return ok
|
|
}
|
|
|
|
// isStrongFile reports whether a comma segment is a self-contained file
|
|
// reference: it carries a read selector (Split strips it) or its basename has an
|
|
// extension. A segment that looks fileish only via a path separator ("src/foo"
|
|
// in the single filename "src/foo,bar.go") is NOT a file boundary, so a comma
|
|
// inside a filename is not mistaken for a multi-file bundle.
|
|
func isStrongFile(part string) bool {
|
|
if path, _, _ := Split(part); path != part {
|
|
return true
|
|
}
|
|
base := part
|
|
if i := strings.LastIndexAny(part, `/\`); i >= 0 {
|
|
base = part[i+1:]
|
|
}
|
|
return strings.Contains(base, ".")
|
|
}
|
|
|
|
func isWindowsDrivePrefix(raw string, colon int) bool {
|
|
return colon == 1 && len(raw) >= 2 && isASCIIAlpha(raw[0])
|
|
}
|
|
|
|
func isASCIIAlpha(b byte) bool {
|
|
return (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z')
|
|
}
|
|
|
|
// parseReadSelector validates the full selector tail (everything after the
|
|
// first ':' in the final segment). Selector parts are joined by ':' so combos
|
|
// such as "2-4:raw" and "raw:2-4" are accepted; each part must be a line-spec
|
|
// list or a recognized mode keyword. The first line-spec encountered drives the
|
|
// returned startLine/lineCount.
|
|
func parseReadSelector(suffix string) (startLine, lineCount int, ok bool) {
|
|
gotLine := false
|
|
for _, part := range strings.Split(suffix, ":") {
|
|
if part == "raw" || part == "conflicts" {
|
|
continue
|
|
}
|
|
s, c, valid := parseLineSpecList(part)
|
|
if !valid {
|
|
return 0, 0, false
|
|
}
|
|
if !gotLine {
|
|
startLine, lineCount, gotLine = s, c, true
|
|
}
|
|
}
|
|
return startLine, lineCount, true
|
|
}
|
|
|
|
// parseLineSpecList parses a comma-separated list of line specs ("5-16,960-973")
|
|
// and reports the start/count of the first spec.
|
|
func parseLineSpecList(part string) (startLine, lineCount int, ok bool) {
|
|
segs := strings.Split(part, ",")
|
|
for i, seg := range segs {
|
|
s, c, valid := parseLineSpec(seg)
|
|
if !valid {
|
|
return 0, 0, false
|
|
}
|
|
if i == 0 {
|
|
startLine, lineCount = s, c
|
|
}
|
|
}
|
|
return startLine, lineCount, true
|
|
}
|
|
|
|
// parseLineSpec parses a single line spec: "N", "N-", "N-M", or "N+K".
|
|
func parseLineSpec(seg string) (startLine, lineCount int, ok bool) {
|
|
if i := strings.IndexByte(seg, '+'); i >= 0 {
|
|
start, errA := strconv.Atoi(seg[:i])
|
|
count, errB := strconv.Atoi(seg[i+1:])
|
|
if errA != nil || errB != nil || start <= 0 || count < 0 {
|
|
return 0, 0, false
|
|
}
|
|
return start, count, true
|
|
}
|
|
if i := strings.IndexByte(seg, '-'); i >= 0 {
|
|
start, errA := strconv.Atoi(seg[:i])
|
|
if errA != nil || start <= 0 {
|
|
return 0, 0, false
|
|
}
|
|
rest := seg[i+1:]
|
|
if rest == "" { // "N-" — open-ended
|
|
return start, 0, true
|
|
}
|
|
end, errB := strconv.Atoi(rest)
|
|
if errB != nil || end < start {
|
|
return 0, 0, false
|
|
}
|
|
return start, end - start + 1, true
|
|
}
|
|
start, err := strconv.Atoi(seg)
|
|
if err != nil || start <= 0 {
|
|
return 0, 0, false
|
|
}
|
|
return start, 0, true
|
|
}
|