Zsh Mailing List Archive
Messages sorted by:
Reverse Date,
Date,
Thread,
Author
[PATCH] hist, subst: add :R modifier for readlink(2)
- X-seq: zsh-workers 54302
- From: dana <dana@xxxxxxx>
- To: zsh-workers@xxxxxxx
- Subject: [PATCH] hist, subst: add :R modifier for readlink(2)
- Date: Mon, 06 Apr 2026 15:40:51 -0500
- Archived-at: <https://zsh.org/workers/54302>
- Feedback-id: i9be146f9:Fastmail
- List-id: <zsh-workers.zsh.org>
i've wished for this for years. you can do it with zstat but it feels
like such an obvious complement to :[aAP]
i was sitting on it but i guess it's fine since it's new functionality
dana
diff --git a/Completion/Zsh/Type/_history_modifiers b/Completion/Zsh/Type/_history_modifiers
index 1a049d6cb..6810b032d 100644
--- a/Completion/Zsh/Type/_history_modifiers
+++ b/Completion/Zsh/Type/_history_modifiers
@@ -74,6 +74,7 @@ while true; do
"e:leave only extension"
"Q:strip quotes"
"P:realpath, resolve '..' physically"
+ "R:readlink, resolve non-recursively"
"l:lower case all words"
"u:upper case all words"
)
diff --git a/Doc/Zsh/expn.yo b/Doc/Zsh/expn.yo
index 0cefaf7d1..1a8905ea2 100644
--- a/Doc/Zsh/expn.yo
+++ b/Doc/Zsh/expn.yo
@@ -308,6 +308,11 @@ zero) that are neither `tt(.)' nor `tt(/)' and that continue to the end
of the string. For example, the extension of
`tt(foo.orig.c)' is `tt(.c)', and `tt(dir.c/foo)' has no extension.
)
+item(tt(R))(
+Resolve a symlink non-recursively, like tt(readlink+LPAR()2+RPAR()). If
+the input file name is not readable or not a symlink, it is returned
+unchanged.
+)
xitem(tt(s/)var(l)tt(/)var(r)[tt(/)])
item(tt(S/)var(l)tt(/)var(r)[tt(/)])(
Substitute var(r) for var(l) as described below.
diff --git a/NEWS b/NEWS
index 4b26c0b03..031cd7817 100644
--- a/NEWS
+++ b/NEWS
@@ -72,6 +72,9 @@ The new completion helper _shadow can be used to temporarily wrap or
substitute a function. The contrib function mkshadow makes it easier
to use outside of completion contexts.
+The new modifier :R can be used in history expansion, parameter
+expansion, and glob qualifiers to non-recursively resolve a symlink.
+
Changes since 5.8.1
-------------------
diff --git a/Src/hist.c b/Src/hist.c
index ce5f7c20e..5f528ec78 100644
--- a/Src/hist.c
+++ b/Src/hist.c
@@ -944,6 +944,13 @@ histsubchar(int c)
}
sline = xsymlink(sline, 1);
break;
+ case 'R':
+ if (!chreadlink(&sline)) {
+ herrflush();
+ zerr("modifier failed: R");
+ return -1;
+ }
+ break;
default:
herrflush();
zerr("illegal modifier: %c", c);
@@ -1873,6 +1880,35 @@ hcomsearch(char *str)
/* various utilities for : modifiers */
+/*
+ * non-recursively resolve a symlink in junkptr. returns 0 on error, >0 on
+ * success. if the given path doesn't exist, isn't readable, or isn't a symlink,
+ * it is left unchanged and >0 is returned
+ */
+
+/**/
+int
+chreadlink(char **junkptr)
+{
+ ssize_t len;
+ char buf[PATH_MAX];
+
+ if (!**junkptr)
+ return 1;
+
+ untokenize(*junkptr);
+
+ if ((len = readlink(unmeta(*junkptr), buf, PATH_MAX)) > 0 &&
+ len < PATH_MAX) {
+ buf[len] = '\0';
+ *junkptr = metafy(buf, len, META_HEAPDUP);
+ return 1;
+ }
+
+ return (len < PATH_MAX &&
+ (errno == ENOENT || errno == EACCES || errno == EINVAL));
+}
+
/**/
int
chabspath(char **junkptr)
diff --git a/Src/subst.c b/Src/subst.c
index 56c1ad6dd..c9da0a8bd 100644
--- a/Src/subst.c
+++ b/Src/subst.c
@@ -4562,6 +4562,7 @@ modify(char **str, char **ptr, int inbrace)
case 'q':
case 'Q':
case 'P':
+ case 'R':
c = **ptr;
break;
@@ -4794,6 +4795,9 @@ modify(char **str, char **ptr, int inbrace)
}
copy = xsymlink(copy, 1);
break;
+ case 'R':
+ chreadlink(©);
+ break;
}
tc = *tt;
*tt = '\0';
@@ -4883,6 +4887,9 @@ modify(char **str, char **ptr, int inbrace)
}
*str = xsymlink(*str, 1);
break;
+ case 'R':
+ chreadlink(str);
+ break;
}
}
if (rec < 0) {
diff --git a/Test/D02glob.ztst b/Test/D02glob.ztst
index 33458e3ba..66234291b 100644
--- a/Test/D02glob.ztst
+++ b/Test/D02glob.ztst
@@ -712,9 +712,22 @@
>16001
print -r ${${:-/a-b=c}:P}
-0:modifier ':P' with tokenised input
+0:modifier ':P' with tokenised input (workers/53671)
>/a-b=c
+ : > myfile.tmp
+ ln -s mysrc.tmp mylink.tmp
+ () { print -r - ${1:R} } myfile.tmp # existing non-link file
+ () { print -r - ${1:R} } mylink.tmp # existing link with non-existent source
+ () { print -r - ${1:R} } nosuch # non-existent link
+ print -r - ${${:-a-b=c}:R} # tokenised input like above
+ rm myfile.tmp mylink.tmp
+0:modifier ':R'
+>myfile.tmp
+>mysrc.tmp
+>nosuch
+>a-b=c
+
foo=a
value="ac"
print ${value//[${foo}b-z]/x}
diff --git a/Test/W01history.ztst b/Test/W01history.ztst
index 1d3f3cf6f..04fc7e31e 100644
--- a/Test/W01history.ztst
+++ b/Test/W01history.ztst
@@ -89,6 +89,21 @@ F:Check that a history bug introduced by workers/34160 is working again.
>/my/path/for/testing
>/my/path/for/testing
+ : > myfile.tmp
+ ln -s mysrc.tmp mylink.tmp
+ $ZTST_testdir/../Src/zsh -fis <<<'
+ : myfile.tmp
+ print -r - !$:R
+ : mylink.tmp
+ print -r - !$:R
+ : nosuch
+ print -r - !$:R' 2>/dev/null
+ rm myfile.tmp mylink.tmp
+0:Modifier :R
+>myfile.tmp
+>mysrc.tmp
+>nosuch
+
$ZTST_testdir/../Src/zsh -fgis <<<'
SAVEHIST=7
print -rs "one\\"
Messages sorted by:
Reverse Date,
Date,
Thread,
Author