返回 DeepSeek-Reasonix
pasted_path.go
根目录 / internal / cli / pasted_path.go
1 package cli
2
3 import (
4 "net/url"
5 "os"
6 pathpkg "path"
7 "path/filepath"
8 "strings"
9
10 "reasonix/internal/shellparse"
11 )
12
13 // pastedPathCandidates returns literal filesystem paths without executing a
14 // shell. Windows tries the native spelling first, then static shell-decoded
15 // forms used by Git Bash, MSYS2, Cygwin, and WSL drive mounts.
16 func pastedPathCandidates(src, goos string, shellDecoded bool) []string {
17 src = strings.TrimSpace(src)
18 src = strings.TrimPrefix(src, "@")
19 if src == "" {
20 return nil
21 }
22
23 var out []string
24 seen := map[string]bool{}
25 add := func(candidate string) {
26 candidate, ok := normalizePastedPathCandidate(candidate, goos)
27 if !ok || seen[candidate] {
28 return
29 }
30 seen[candidate] = true
31 out = append(out, candidate)
32 }
33
34 if shellDecoded {
35 add(src)
36 return out
37 }
38
39 inner, quoted := trimMatchingPathQuotes(src)
40 if goos == "windows" {
41 if quoted {
42 add(inner)
43 } else if !hasUnescapedPathWhitespace(src) {
44 add(src)
45 }
46 }
47
48 if fields, malformed := shellparse.StaticFields(src); malformed == "" && len(fields) == 1 {
49 add(fields[0])
50 }
51
52 if goos == "windows" {
53 if !quoted && !hasUnescapedPathWhitespace(src) && strings.Contains(src, `\`) {
54 add(unescapeWindowsShellPath(src))
55 }
56 } else if quoted {
57 add(inner)
58 } else if !hasUnescapedPathWhitespace(src) {
59 // Preserve the pre-existing POSIX behavior for unusual inputs the static
60 // parser rejects, such as a trailing literal backslash.
61 add(unescapeShellPath(src))
62 }
63
64 return out
65 }
66
67 func resolveExistingPastedPath(src, goos string, shellDecoded bool, exists func(string) bool) (string, bool) {
68 if exists == nil {
69 return "", false
70 }
71 for _, candidate := range pastedPathCandidates(src, goos, shellDecoded) {
72 if exists(candidate) {
73 return candidate, true
74 }
75 }
76 return "", false
77 }
78
79 func pastedPathExists(path string) bool {
80 info, err := os.Stat(path)
81 return err == nil && !info.IsDir()
82 }
83
84 func trimMatchingPathQuotes(src string) (string, bool) {
85 if len(src) < 2 {
86 return src, false
87 }
88 first, last := src[0], src[len(src)-1]
89 if (first == '\'' || first == '"') && first == last {
90 return src[1 : len(src)-1], true
91 }
92 return src, false
93 }
94
95 func normalizePastedPathCandidate(src, goos string) (string, bool) {
96 if src == "" {
97 return "", false
98 }
99 lower := strings.ToLower(src)
100 if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
101 return "", false
102 }
103 if strings.HasPrefix(lower, "file://") {
104 u, err := url.Parse(src)
105 if err != nil || u.Path == "" {
106 return "", false
107 }
108 src = u.Path
109 if goos == "windows" && u.Host != "" {
110 src = "//" + u.Host + u.Path
111 }
112 }
113 if strings.HasPrefix(src, "~/") {
114 home, err := os.UserHomeDir()
115 if err == nil && home != "" {
116 src = filepath.Join(home, strings.TrimPrefix(src, "~/"))
117 }
118 }
119 if goos == "windows" {
120 src = normalizeWindowsPOSIXPath(src)
121 return src, src != ""
122 }
123 return pathpkg.Clean(src), true
124 }
125
126 func normalizeWindowsPOSIXPath(src string) string {
127 if len(src) >= 4 && src[0] == '/' && isASCIIAlpha(src[1]) && src[2] == ':' && src[3] == '/' {
128 return strings.ToUpper(src[1:2]) + src[2:]
129 }
130 if len(src) >= 3 && src[0] == '/' && isASCIIAlpha(src[1]) && src[2] == '/' {
131 return strings.ToUpper(src[1:2]) + ":" + src[2:]
132 }
133 lower := strings.ToLower(src)
134 if len(src) >= 7 && strings.HasPrefix(lower, "/mnt/") && isASCIIAlpha(src[5]) && src[6] == '/' {
135 return strings.ToUpper(src[5:6]) + ":" + src[6:]
136 }
137 const cygdrive = "/cygdrive/"
138 if len(src) >= len(cygdrive)+2 && strings.HasPrefix(lower, cygdrive) && isASCIIAlpha(src[len(cygdrive)]) && src[len(cygdrive)+1] == '/' {
139 return strings.ToUpper(src[len(cygdrive):len(cygdrive)+1]) + ":" + src[len(cygdrive)+1:]
140 }
141 return src
142 }
143
144 // unescapeWindowsShellPath keeps likely native separator backslashes while
145 // decoding shell escapes. This candidate is only used after the exact native
146 // spelling fails the caller's existence check, so punctuation-led native path
147 // components still resolve through the native candidate first.
148 func unescapeWindowsShellPath(src string) string {
149 var b strings.Builder
150 b.Grow(len(src))
151 i := 0
152 // A leading double backslash is a UNC or device prefix (\\server\share,
153 // \\?\C:\..., \\.\...), not an escape pair; keep it verbatim.
154 if strings.HasPrefix(src, `\\`) {
155 b.WriteString(`\\`)
156 i = 2
157 }
158 for ; i < len(src); i++ {
159 if src[i] != '\\' || i+1 >= len(src) {
160 b.WriteByte(src[i])
161 continue
162 }
163 next := src[i+1]
164 if windowsPathComponentByte(next) {
165 b.WriteByte(src[i])
166 continue
167 }
168 b.WriteByte(next)
169 i++
170 }
171 return b.String()
172 }
173
174 func windowsPathComponentByte(ch byte) bool {
175 return ch >= 0x80 || ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9' || ch == '.' || ch == '_' || ch == '-'
176 }
177
178 func isASCIIAlpha(ch byte) bool {
179 return ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z'
180 }
181
181 lines GO