Skip to content

Commit 226277f

Browse files
committed
fix: expand leading ~ in path builtins
Path builtins were inconsistent about tilde expansion. The shell expands ~, and get_path's full_path did too, but write_file, read_file, find_paths, and delete_path handed the raw string straight to the OS - so the same "~/foo" path that worked in a shell command failed on the adjacent builtin call. get_path was even self-contradictory: full_path resolved ~ while exists checked the un-expanded path, so .exists could be false while .full_path pointed at a real file. These builtins now expand a leading ~ consistently, matching what users expect coming from the shell. Expansion is deliberately narrow: only an exact "~" or a "~/" prefix resolves to home. A literal name like "~backup" and an unsupported "~user/..." are left untouched rather than silently misrouted to $HOME/..., which preserves the prior raw-path behavior for those cases. If the home dir can't be resolved, the path is left as-is so the failure surfaces honestly at the OS call instead of becoming "/foo". Tilde-only expansion is kept separate from absolutization: relative paths are still passed through unchanged, so behavior only differs for tilde inputs. Closes #125
1 parent 686be9d commit 226277f

15 files changed

Lines changed: 168 additions & 13 deletions

File tree

core/common/files.go

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,23 @@ import (
88
"strings"
99
)
1010

11-
func ToAbsolutePath(path string) string {
12-
if strings.HasPrefix(path, "~") {
13-
home, _ := os.UserHomeDir() // todo technically should handle err
14-
path = filepath.Join(home, path[1:]) // drop the "~"
11+
// ExpandTilde resolves a leading "~" (i.e. exactly "~" or a "~/" prefix) to the
12+
// user's home directory. Anything else is returned unchanged: "~user" (another
13+
// user's home) is not supported and is left as a literal path rather than
14+
// silently misexpanded, and a path like "~backup" is treated as a literal name.
15+
// If the home dir can't be resolved, the path is returned untouched so the
16+
// failure surfaces honestly at the os call site.
17+
func ExpandTilde(path string) string {
18+
if path == "~" || strings.HasPrefix(path, "~/") {
19+
if home, err := os.UserHomeDir(); err == nil {
20+
path = filepath.Join(home, path[1:]) // drop the "~"
21+
}
1522
}
16-
abs, _ := filepath.Abs(path) // todo handle err?
23+
return path
24+
}
25+
26+
func ToAbsolutePath(path string) string {
27+
abs, _ := filepath.Abs(ExpandTilde(path)) // todo handle err?
1728
return abs
1829
}
1930

core/common/files_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
func TestExpandTilde(t *testing.T) {
10+
home, err := os.UserHomeDir()
11+
if err != nil {
12+
t.Fatalf("could not resolve home dir: %v", err)
13+
}
14+
15+
tests := []struct {
16+
name string
17+
in string
18+
want string
19+
}{
20+
{"bare tilde", "~", home},
21+
{"tilde with subpath", "~/foo/bar.txt", filepath.Join(home, "foo/bar.txt")},
22+
{"no tilde absolute", "/etc/hosts", "/etc/hosts"},
23+
{"no tilde relative", "foo/bar.txt", "foo/bar.txt"},
24+
{"tilde mid-string untouched", "/foo/~/bar", "/foo/~/bar"},
25+
{"tilde literal name untouched", "~backup", "~backup"},
26+
{"tilde user untouched", "~bob/config.txt", "~bob/config.txt"},
27+
}
28+
29+
for _, tt := range tests {
30+
t.Run(tt.name, func(t *testing.T) {
31+
if got := ExpandTilde(tt.in); got != tt.want {
32+
t.Errorf("ExpandTilde(%q) = %q, want %q", tt.in, got, tt.want)
33+
}
34+
})
35+
}
36+
}
37+
38+
func TestToAbsolutePathExpandsTilde(t *testing.T) {
39+
home, err := os.UserHomeDir()
40+
if err != nil {
41+
t.Fatalf("could not resolve home dir: %v", err)
42+
}
43+
44+
want := filepath.Join(home, "foo/bar.txt")
45+
if got := ToAbsolutePath("~/foo/bar.txt"); got != want {
46+
t.Errorf("ToAbsolutePath(\"~/foo/bar.txt\") = %q, want %q", got, want)
47+
}
48+
}

core/funcs.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -790,7 +790,7 @@ func init() {
790790

791791
radMap.SetPrimitiveStr(constFullPath, NormalizePath(absPath))
792792

793-
stat, err1 := os.Stat(path)
793+
stat, err1 := os.Stat(absPath)
794794
if err1 == nil {
795795
radMap.SetPrimitiveStr(constBaseName, stat.Name())
796796
radMap.SetPrimitiveStr(constPermissions, stat.Mode().Perm().String())
@@ -835,7 +835,7 @@ func init() {
835835
relativeMode, []string{constTarget, constCwd, constAbsolute})
836836
}
837837

838-
absTarget, err := filepath.Abs(path) // todo should be abstracted away for testing
838+
absTarget, err := filepath.Abs(com.ExpandTilde(path)) // todo should be abstracted away for testing
839839
if err != nil {
840840
return f.ReturnErrf(rl.ErrGenericRuntime, "Error resolving absolute path for target: %v", err)
841841
}
@@ -901,7 +901,7 @@ func init() {
901901
// todo should offer args like find_paths
902902
Name: FUNC_DELETE_PATH,
903903
Execute: func(f FuncInvocation) RadValue {
904-
path := f.GetStr("_path").Plain()
904+
path := com.ExpandTilde(f.GetStr("_path").Plain())
905905
deleted := false
906906

907907
if _, err := os.Stat(path); err == nil {
@@ -1119,7 +1119,7 @@ func init() {
11191119
// - tail # Last N bytes
11201120
Name: FUNC_READ_FILE,
11211121
Execute: func(f FuncInvocation) RadValue {
1122-
path := f.GetStr("_path").Plain()
1122+
path := com.ExpandTilde(f.GetStr("_path").Plain())
11231123
mode := f.GetStr("mode").Plain()
11241124

11251125
data, err := os.ReadFile(path)
@@ -1158,7 +1158,7 @@ func init() {
11581158
{
11591159
Name: FUNC_WRITE_FILE,
11601160
Execute: func(f FuncInvocation) RadValue {
1161-
path := f.GetStr("_path").Plain()
1161+
path := com.ExpandTilde(f.GetStr("_path").Plain())
11621162
content := f.GetStr("_content").Plain()
11631163
appendFlag := f.GetBool("append")
11641164

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
//go:build unix
2+
3+
package testing
4+
5+
import (
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
)
11+
12+
// Issue 125: path builtins should expand a leading "~" consistently, matching
13+
// shell behavior. These tests point $HOME at a temp dir (os.UserHomeDir reads
14+
// $HOME on unix) so "~" resolves there without touching the real home.
15+
16+
func Test_PathTilde_WriteReadDeleteRoundTrip(t *testing.T) {
17+
tmpHome := t.TempDir()
18+
t.Setenv("HOME", tmpHome)
19+
20+
script := `
21+
w = write_file("~/issue125.txt", "hi there")
22+
print(w.path)
23+
print(read_file("~/issue125.txt").content)
24+
print(delete_path("~/issue125.txt"))
25+
`
26+
setupAndRunCode(t, script, "--color=never")
27+
expected := fmt.Sprintf("%s/issue125.txt\nhi there\ntrue\n", filepath.ToSlash(tmpHome))
28+
assertOnlyOutput(t, stdOutBuffer, expected)
29+
assertNoErrors(t)
30+
31+
if _, err := os.Stat(filepath.Join(tmpHome, "issue125.txt")); !os.IsNotExist(err) {
32+
t.Errorf("expected file to be deleted, stat err = %v", err)
33+
}
34+
}
35+
36+
// The headline inconsistency from the issue: get_path("~/...").exists was false
37+
// even when full_path pointed at a real file.
38+
func Test_PathTilde_GetPathExists(t *testing.T) {
39+
tmpHome := t.TempDir()
40+
t.Setenv("HOME", tmpHome)
41+
42+
target := filepath.Join(tmpHome, "exists_check.txt")
43+
if err := os.WriteFile(target, []byte("x"), 0644); err != nil {
44+
t.Fatalf("failed to seed file: %v", err)
45+
}
46+
47+
script := `
48+
p = get_path("~/exists_check.txt")
49+
print(p.exists)
50+
print(p.full_path)
51+
`
52+
setupAndRunCode(t, script, "--color=never")
53+
expected := fmt.Sprintf("true\n%s/exists_check.txt\n", filepath.ToSlash(tmpHome))
54+
assertOnlyOutput(t, stdOutBuffer, expected)
55+
assertNoErrors(t)
56+
}
57+
58+
func Test_PathTilde_FindPaths(t *testing.T) {
59+
tmpHome := t.TempDir()
60+
t.Setenv("HOME", tmpHome)
61+
62+
if err := os.WriteFile(filepath.Join(tmpHome, "a.txt"), []byte("a"), 0644); err != nil {
63+
t.Fatalf("failed to seed file: %v", err)
64+
}
65+
66+
script := `
67+
print(find_paths("~"))
68+
`
69+
setupAndRunCode(t, script, "--color=never")
70+
assertOnlyOutput(t, stdOutBuffer, "[ \"a.txt\" ]\n")
71+
assertNoErrors(t)
72+
}

docs-web/docs/reference/functions.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -755,6 +755,8 @@ delete_path("directory/") // -> true (if directory existed and was deleted
755755

756756
Returns `true` if the path was successfully deleted, `false` if it didn't exist or couldn't be deleted.
757757

758+
A leading `~` in `_path` is expanded to your home directory.
759+
758760
### find_paths
759761

760762
Returns a list of all paths under a directory.
@@ -788,6 +790,8 @@ paths = find_paths("src/", relative="absolute")
788790
- `"cwd"` - Relative to current directory
789791
- `"absolute"` - Full absolute paths
790792

793+
A leading `~` in `_path` is expanded to your home directory.
794+
791795
**Examples:**
792796

793797
### input
@@ -1001,6 +1005,8 @@ if not result.success:
10011005

10021006
In text mode, decodes as UTF-8 and returns a string. In bytes mode, returns a list of integers.
10031007

1008+
A leading `~` in `_path` is expanded to your home directory.
1009+
10041010
**Return map contains:**
10051011

10061012
- `size_bytes: int` - File size in bytes
@@ -1056,6 +1062,8 @@ if err:
10561062

10571063
By default overwrites the file. Use `append=true` to append to existing content.
10581064

1065+
A leading `~` in `_path` is expanded to your home directory.
1066+
10591067
**Return map contains:**
10601068

10611069
- `bytes_written: int` - Number of bytes written
@@ -2235,7 +2243,7 @@ if info.exists:
22352243
**Always returns:**
22362244

22372245
- `exists: bool` - Whether the path exists
2238-
- `full_path: str` - Absolute path
2246+
- `full_path: str` - Absolute path (a leading `~` in `_path` is expanded to your home directory)
22392247

22402248
**When path exists, also returns:**
22412249

docs/funcs/delete_path.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,5 @@ io
2121
## Notes
2222

2323
Returns `true` if the path was successfully deleted, `false` if it didn't exist or couldn't be deleted.
24+
25+
A leading `~` in `_path` is expanded to your home directory.

docs/funcs/find_paths.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,6 @@ io
3939
- `"cwd"` - Relative to current directory
4040
- `"absolute"` - Full absolute paths
4141

42+
A leading `~` in `_path` is expanded to your home directory.
43+
4244
**Examples:**

docs/funcs/get_path.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ system
3232
**Always returns:**
3333

3434
- `exists: bool` - Whether the path exists
35-
- `full_path: str` - Absolute path
35+
- `full_path: str` - Absolute path (a leading `~` in `_path` is expanded to your home directory)
3636

3737
**When path exists, also returns:**
3838

docs/funcs/read_file.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ io
4040

4141
In text mode, decodes as UTF-8 and returns a string. In bytes mode, returns a list of integers.
4242

43+
A leading `~` in `_path` is expanded to your home directory.
44+
4345
**Return map contains:**
4446

4547
- `size_bytes: int` - File size in bytes

docs/funcs/write_file.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ io
3838

3939
By default overwrites the file. Use `append=true` to append to existing content.
4040

41+
A leading `~` in `_path` is expanded to your home directory.
42+
4143
**Return map contains:**
4244

4345
- `bytes_written: int` - Number of bytes written

0 commit comments

Comments
 (0)