From 7cca5fc3279cb5bf54bb37344164078dbee53b00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A1draig=20Brady?= Date: Mon, 21 Aug 2017 03:53:36 -0700 Subject: [PATCH] ls: support --hyperlink to output file:// URIs Terminals such as iTerm2 and VTE based terminals (as of version 0.49.1), support hyperlinks when passed terminals codes as described at: https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda * src/ls.c (gobble_file): Allocate an absolute file name to output. (quote_name): Output the absolute name with the appropriate codes. (file_escape): A new function to encode file names as per rfc8089. (main): Handle the new option and call the file_escape_init() helper. Disable --dired when --hyperlink is specified. (print_dir): Get the absolute file name here too, so that the directory name can be linkified. * NEWS: Mention the new feature. * tests/ls/hyperlink.sh: Add a new test. * tests/local.mk: Reference the new test. * doc/coreutils.texi (ls invocation): Describe --hyperlink. --- NEWS | 3 + doc/coreutils.texi | 26 ++++++- src/ls.c | 198 ++++++++++++++++++++++++++++++++++++++++---------- tests/local.mk | 1 + tests/ls/hyperlink.sh | 61 ++++++++++++++++ 5 files changed, 248 insertions(+), 41 deletions(-) create mode 100755 tests/ls/hyperlink.sh diff --git a/NEWS b/NEWS index d37195e..00bfe5a 100644 --- a/NEWS +++ b/NEWS @@ -81,6 +81,9 @@ GNU coreutils NEWS -*- outline -*- by prefixing the last specified number like --tabs=1,+8 which is useful for visualizing diff output for example. + ls supports a new --hyperlink[=when] option to output file:// + format links to files, supported by some terminals. + split supports a new --hex-suffixes[=from] option to create files with lower case hexadecimal suffixes, similar to the --numeric-suffixes option. diff --git a/doc/coreutils.texi b/doc/coreutils.texi index 8f1cb4c..173f064 100644 --- a/doc/coreutils.texi +++ b/doc/coreutils.texi @@ -7857,9 +7857,8 @@ may be omitted, or one of: @end itemize Specifying @option{--color} and no @var{when} is equivalent to @option{--color=always}. -Piping a colorized listing through a pager like @command{more} or -@command{less} usually produces unreadable results. However, using -@code{more -f} does seem to work. +If piping a colorized listing through a pager like @command{less}, +use the @option{-R} option to pass the color codes to the terminal. @vindex LS_COLORS @vindex SHELL @r{environment variable, and color} @@ -7905,6 +7904,27 @@ command line unless the @option{--dereference-command-line} (@option{-H}), Append a character to each file name indicating the file type. This is like @option{-F}, except that executables are not marked. +@item --hyperlink [=@var{when}] +@opindex --hyperlink +@cindex hyperlink, linking to files +Output codes recognized by some terminals to link +to files using the @samp{file://} URI format. +@var{when} may be omitted, or one of: +@itemize @bullet +@item none +@vindex none @r{hyperlink option} +- Do not use hyperlinks at all. This is the default. +@item auto +@vindex auto @r{hyperlink option} +@cindex terminal, using hyperlink iff +- Only use hyperlinks if standard output is a terminal. +@item always +@vindex always @r{hyperlink option} +- Always use hyperlinks. +@end itemize +Specifying @option{--hyperlink} and no @var{when} is equivalent to +@option{--hyperlink=always}. + @item --indicator-style=@var{word} @opindex --indicator-style Append a character indicator with style @var{word} to entry names, diff --git a/src/ls.c b/src/ls.c index bb97e98..9ed4913 100644 --- a/src/ls.c +++ b/src/ls.c @@ -110,6 +110,9 @@ #include "areadlink.h" #include "mbsalign.h" #include "dircolors.h" +#include "xgethostname.h" +#include "c-ctype.h" +#include "canonicalize.h" /* Include last to avoid a clash of include guards with some premature versions of libcap. @@ -200,6 +203,9 @@ struct fileinfo /* For symbolic link, name of the file linked to, otherwise zero. */ char *linkname; + /* For terminal hyperlinks. */ + char *absolute_name; + struct stat stat; enum filetype filetype; @@ -248,7 +254,8 @@ static size_t quote_name (char const *name, struct quoting_options const *options, int needs_general_quoting, const struct bin_str *color, - bool allow_pad, struct obstack *stack); + bool allow_pad, struct obstack *stack, + char const *absolute_name); static size_t quote_name_buf (char **inbuf, size_t bufsize, char *name, struct quoting_options const *options, int needs_general_quoting, size_t *width, @@ -346,6 +353,8 @@ static size_t sorted_file_alloc; static bool color_symlink_as_referent; +static char const *hostname; + /* mode of appropriate file for colorization */ #define FILE_OR_LINK_MODE(File) \ ((color_symlink_as_referent && (File)->linkok) \ @@ -548,17 +557,19 @@ ARGMATCH_VERIFY (indicator_style_args, indicator_style_types); static bool print_with_color; +static bool print_hyperlink; + /* Whether we used any colors in the output so far. If so, we will need to restore the default color later. If not, we will need to call prep_non_filename_text before using color for the first time. */ static bool used_color = false; -enum color_type +enum when_type { - color_never, /* 0: default or --color=never */ - color_always, /* 1: --color=always */ - color_if_tty /* 2: --color=tty */ + when_never, /* 0: default or --color=never */ + when_always, /* 1: --color=always */ + when_if_tty /* 2: --color=tty */ }; enum Dereference_symlink @@ -814,6 +825,7 @@ enum FULL_TIME_OPTION, GROUP_DIRECTORIES_FIRST_OPTION, HIDE_OPTION, + HYPERLINK_OPTION, INDICATOR_STYLE_OPTION, QUOTING_STYLE_OPTION, SHOW_CONTROL_CHARS_OPTION, @@ -864,6 +876,7 @@ static struct option const long_options[] = {"time", required_argument, NULL, TIME_OPTION}, {"time-style", required_argument, NULL, TIME_STYLE_OPTION}, {"color", optional_argument, NULL, COLOR_OPTION}, + {"hyperlink", optional_argument, NULL, HYPERLINK_OPTION}, {"block-size", required_argument, NULL, BLOCK_SIZE_OPTION}, {"context", no_argument, 0, 'Z'}, {"author", no_argument, NULL, AUTHOR_OPTION}, @@ -904,20 +917,20 @@ static enum time_type const time_types[] = }; ARGMATCH_VERIFY (time_args, time_types); -static char const *const color_args[] = +static char const *const when_args[] = { /* force and none are for compatibility with another color-ls version */ "always", "yes", "force", "never", "no", "none", "auto", "tty", "if-tty", NULL }; -static enum color_type const color_types[] = +static enum when_type const when_types[] = { - color_always, color_always, color_always, - color_never, color_never, color_never, - color_if_tty, color_if_tty, color_if_tty + when_always, when_always, when_always, + when_never, when_never, when_never, + when_if_tty, when_if_tty, when_if_tty }; -ARGMATCH_VERIFY (color_args, color_types); +ARGMATCH_VERIFY (when_args, when_types); /* Information about filling a column. */ struct column_info @@ -1066,6 +1079,14 @@ first_percent_b (char const *fmt) return NULL; } +static char RFC3986[256]; +static void +file_escape_init (void) +{ + for (int i = 0; i < 256; i++) + RFC3986[i] |= c_isalnum (i) || i == '~' || i == '-' || i == '.' || i == '_'; +} + /* Read the abbreviated month names from the locale, to align them and to determine the max width of the field and to truncate names greater than our max allowed. @@ -1500,6 +1521,17 @@ main (int argc, char **argv) obstack_init (&subdired_obstack); } + if (print_hyperlink) + { + file_escape_init (); + + hostname = xgethostname (); + /* The hostname is generally ignored, + so ignore failures obtaining it. */ + if (! hostname) + hostname = ""; + } + cwd_n_alloc = 100; cwd_file = xnmalloc (cwd_n_alloc, sizeof *cwd_file); cwd_n_used = 0; @@ -1783,6 +1815,7 @@ decode_switches (int argc, char **argv) format = (isatty (STDOUT_FILENO) ? many_per_line : one_per_line); print_block_size = false; /* disable -s */ print_with_color = false; /* disable --color */ + print_hyperlink = false; /* disable --hyperlink */ break; case FILE_TYPE_INDICATOR_OPTION: /* --file-type */ @@ -1985,14 +2018,14 @@ decode_switches (int argc, char **argv) { int i; if (optarg) - i = XARGMATCH ("--color", optarg, color_args, color_types); + i = XARGMATCH ("--color", optarg, when_args, when_types); else /* Using --color with no argument is equivalent to using --color=always. */ - i = color_always; + i = when_always; - print_with_color = (i == color_always - || (i == color_if_tty + print_with_color = (i == when_always + || (i == when_if_tty && isatty (STDOUT_FILENO))); if (print_with_color) @@ -2005,6 +2038,22 @@ decode_switches (int argc, char **argv) break; } + case HYPERLINK_OPTION: + { + int i; + if (optarg) + i = XARGMATCH ("--hyperlink", optarg, when_args, when_types); + else + /* Using --hyperlink with no argument is equivalent to using + --hyperlink=always. */ + i = when_always; + + print_hyperlink = (i == when_always + || (i == when_if_tty + && isatty (STDOUT_FILENO))); + break; + } + case INDICATOR_STYLE_OPTION: indicator_style = XARGMATCH ("--indicator-style", optarg, indicator_style_args, @@ -2102,7 +2151,7 @@ decode_switches (int argc, char **argv) /* --dired is meaningful only with --format=long (-l). Otherwise, ignore it. FIXME: warn about this? Alternatively, make --dired imply --format=long? */ - if (dired && format != long_format) + if (dired && (format != long_format || print_hyperlink)) dired = false; /* If -c or -u is specified and not -l (or any other option that implies -l), @@ -2715,8 +2764,18 @@ print_dir (char const *name, char const *realname, bool command_line_arg) first = false; DIRED_INDENT (); + char *absolute_name = NULL; + if (print_hyperlink) + { + absolute_name = canonicalize_filename_mode (name, CAN_MISSING); + if (! absolute_name) + file_failure (command_line_arg, + _("error canonicalizing %s"), name); + } quote_name (realname ? realname : name, dirname_quoting_options, -1, - NULL, true, &subdired_obstack); + NULL, true, &subdired_obstack, absolute_name); + + free (absolute_name); DIRED_FPUTS_LITERAL (":\n", stdout); } @@ -2909,6 +2968,7 @@ free_ent (struct fileinfo *f) { free (f->name); free (f->linkname); + free (f->absolute_name); if (f->scontext != UNKNOWN_SECURITY_CONTEXT) { if (is_smack_enabled ()) @@ -3072,6 +3132,7 @@ gobble_file (char const *name, enum filetype type, ino_t inode, } if (command_line_arg + || print_hyperlink || format_needs_stat /* When coloring a directory (we may know the type from direct.d_type), we have to stat it in order to indicate @@ -3110,22 +3171,31 @@ gobble_file (char const *name, enum filetype type, ino_t inode, { /* Absolute name of this file. */ - char *absolute_name; + char *full_name; bool do_deref; int err; if (name[0] == '/' || dirname[0] == 0) - absolute_name = (char *) name; + full_name = (char *) name; else { - absolute_name = alloca (strlen (name) + strlen (dirname) + 2); - attach (absolute_name, dirname, name); + full_name = alloca (strlen (name) + strlen (dirname) + 2); + attach (full_name, dirname, name); + } + + if (print_hyperlink) + { + f->absolute_name = canonicalize_filename_mode (full_name, + CAN_MISSING); + if (! f->absolute_name) + file_failure (command_line_arg, + _("error canonicalizing %s"), full_name); } switch (dereference) { case DEREF_ALWAYS: - err = stat (absolute_name, &f->stat); + err = stat (full_name, &f->stat); do_deref = true; break; @@ -3134,7 +3204,7 @@ gobble_file (char const *name, enum filetype type, ino_t inode, if (command_line_arg) { bool need_lstat; - err = stat (absolute_name, &f->stat); + err = stat (full_name, &f->stat); do_deref = true; if (dereference == DEREF_COMMAND_LINE_ARGUMENTS) @@ -3147,14 +3217,14 @@ gobble_file (char const *name, enum filetype type, ino_t inode, break; /* stat failed because of ENOENT, maybe indicating a dangling - symlink. Or stat succeeded, ABSOLUTE_NAME does not refer to a + symlink. Or stat succeeded, FULL_NAME does not refer to a directory, and --dereference-command-line-symlink-to-dir is in effect. Fall through so that we call lstat instead. */ } FALLTHROUGH; default: /* DEREF_NEVER */ - err = lstat (absolute_name, &f->stat); + err = lstat (full_name, &f->stat); do_deref = false; break; } @@ -3165,7 +3235,7 @@ gobble_file (char const *name, enum filetype type, ino_t inode, an exit status of 2. For other files, stat failure provokes an exit status of 1. */ file_failure (command_line_arg, - _("cannot access %s"), absolute_name); + _("cannot access %s"), full_name); if (command_line_arg) return 0; @@ -3180,13 +3250,13 @@ gobble_file (char const *name, enum filetype type, ino_t inode, /* Note has_capability() adds around 30% runtime to 'ls --color' */ if ((type == normal || S_ISREG (f->stat.st_mode)) && print_with_color && is_colored (C_CAP)) - f->has_capability = has_capability_cache (absolute_name, f); + f->has_capability = has_capability_cache (full_name, f); if (format == long_format || print_scontext) { bool have_scontext = false; bool have_acl = false; - int attr_len = getfilecon_cache (absolute_name, f, do_deref); + int attr_len = getfilecon_cache (full_name, f, do_deref); err = (attr_len < 0); if (err == 0) @@ -3210,7 +3280,7 @@ gobble_file (char const *name, enum filetype type, ino_t inode, if (err == 0 && format == long_format) { - int n = file_has_acl_cache (absolute_name, f); + int n = file_has_acl_cache (full_name, f); err = (n < 0); have_acl = (0 < n); } @@ -3223,7 +3293,7 @@ gobble_file (char const *name, enum filetype type, ino_t inode, any_has_acl |= f->acl_type != ACL_T_NONE; if (err) - error (0, errno, "%s", quotef (absolute_name)); + error (0, errno, "%s", quotef (full_name)); } if (S_ISLNK (f->stat.st_mode) @@ -3231,8 +3301,8 @@ gobble_file (char const *name, enum filetype type, ino_t inode, { struct stat linkstats; - get_link_name (absolute_name, f, command_line_arg); - char *linkname = make_link_name (absolute_name, f->linkname); + get_link_name (full_name, f, command_line_arg); + char *linkname = make_link_name (full_name, f->linkname); /* Use the slower quoting path for this entry, though don't update CWD_SOME_QUOTED since alignment not affected. */ @@ -4373,10 +4443,33 @@ quote_name_width (const char *name, struct quoting_options const *options, return width; } +/* %XX escape any input out of range as defined in RFC3986, + and also if PATH, convert all path separators to '/'. */ +static char * +file_escape (const char *str, bool path) +{ + char *esc = xnmalloc (3, strlen (str) + 1); + char *p = esc; + while (*str) + { + if (path && ISSLASH (*str)) + { + *p++ = '/'; + str++; + } + else if (RFC3986[to_uchar (*str)]) + *p++ = *str++; + else + p += sprintf (p, "%%%02x", to_uchar (*str++)); + } + *p = '\0'; + return esc; +} + static size_t quote_name (char const *name, struct quoting_options const *options, int needs_general_quoting, const struct bin_str *color, - bool allow_pad, struct obstack *stack) + bool allow_pad, struct obstack *stack, char const *absolute_name) { char smallbuf[BUFSIZ]; char *buf = smallbuf; @@ -4392,19 +4485,44 @@ quote_name (char const *name, struct quoting_options const *options, if (color) print_color_indicator (color); + /* If we're padding, then don't include the outer quotes in + the --hyperlink, to improve the alignment of those links. */ + bool skip_quotes = false; + + if (absolute_name) + { + if (align_variable_outer_quotes && cwd_some_quoted && ! pad) + { + skip_quotes = true; + putchar (*buf); + } + char *h = file_escape (hostname, /* path= */ false); + char *n = file_escape (absolute_name, /* path= */ true); + printf ("\033]8;;file://%s%s%s\a", h, *n == '/' ? "" : "/", n); + free (h); + free (n); + } + if (stack) PUSH_CURRENT_DIRED_POS (stack); - fwrite (buf, 1, len, stdout); - - if (buf != smallbuf && buf != name) - free (buf); + fwrite (buf + skip_quotes, 1, len - skip_quotes, stdout); dired_pos += len; if (stack) PUSH_CURRENT_DIRED_POS (stack); + if (absolute_name) + { + fputs ("\033]8;;\a", stdout); + if (skip_quotes) + putchar (*(buf + len - 1)); + } + + if (buf != smallbuf && buf != name) + free (buf); + return len + pad; } @@ -4423,7 +4541,7 @@ print_name_with_quoting (const struct fileinfo *f, && (color || is_colored (C_NORM))); size_t len = quote_name (name, filename_quoting_options, f->quoted, - color, !symlink_target, stack); + color, !symlink_target, stack, f->absolute_name); process_signals (); if (used_color_this_time) @@ -5069,6 +5187,10 @@ Sort entries alphabetically if none of -cftuvSUX nor --sort is specified.\n\ (overridden by -a or -A)\n\ "), stdout); fputs (_("\ + --hyperlink[=WHEN] hyperlink file names; WHEN can be 'always'\n\ + (default if omitted), 'auto', or 'never'\n\ +"), stdout); + fputs (_("\ --indicator-style=WORD append indicator with style WORD to entry names:\ \n\ none (default), slash (-p),\n\ diff --git a/tests/local.mk b/tests/local.mk index fd4713d..732ec99 100644 --- a/tests/local.mk +++ b/tests/local.mk @@ -607,6 +607,7 @@ all_tests = \ tests/ls/symlink-slash.sh \ tests/ls/time-style-diag.sh \ tests/ls/x-option.sh \ + tests/ls/hyperlink.sh \ tests/mkdir/p-1.sh \ tests/mkdir/p-2.sh \ tests/mkdir/p-3.sh \ diff --git a/tests/ls/hyperlink.sh b/tests/ls/hyperlink.sh new file mode 100755 index 0000000..bc13311 --- /dev/null +++ b/tests/ls/hyperlink.sh @@ -0,0 +1,61 @@ +#!/bin/sh +# Test --hyperlink processing + +# Copyright (C) 2017 Free Software Foundation, Inc. + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +. "${srcdir=.}/tests/init.sh"; path_prepend_ ./src +print_ver_ ls realpath + +hostname=$(hostname) || skip_ 'unable to determine hostname' + +# lookup based on first letter +encode() { + printf '%s\n' \ + 'sp%20ace' 'ques%3ftion' 'back%5cslash' 'encoded%253Fquestion' 'testdir' \ + "$1" | + sort -k1,1.1 -s | uniq -w1 -d +} + +ls_encoded() { + ef=$(encode "$1") + echo "$ef" | grep -q 'dir$' && dir=: || dir='' + printf '\033]8;;file://%s%s/%s\a%s\033]8;;\a%s\n' \ + $hostname $basepath $ef "$1" "$dir" +} + +mkdir testdir || framework_failure_ +basepath=$(realpath -m .) || framework_failure_ +( +cd testdir +ls_encoded "testdir" > ../exp.t || framework_failure_ +basepath="$basepath/testdir" +for f in 'back\slash' 'encoded%3Fquestion' 'ques?tion' 'sp ace'; do + touch "$f" || framework_failure_ + ls_encoded "$f" >> ../exp.t || framework_failure_ +done +) +ln -s testdir testdirl || framework_failure_ +(cat exp.t && echo && sed 's/[^\/]testdir/&l/' exp.t) > exp \ + || framework_failure_ +ls --hyper testdir testdirl >out || fail=1 +compare exp out || fail=1 + +ln -s '/probably/missing' testlink || framework_failure_ +target=$(realpath -m testlink) || framework_failure_ +ls -l --hyper testlink > out || fail=1 +grep "file://.*$target" out || fail=1 + +Exit $fail -- 2.9.3