diff --git a/common/realpath/args.v b/common/realpath/args.v new file mode 100644 index 00000000..fbd45c84 --- /dev/null +++ b/common/realpath/args.v @@ -0,0 +1,133 @@ +module realpath + +import common + +pub fn parse_args(args []string) Settings { + mut s := Settings{} + mut operands := []string{} + + for i := 0; i < args.len; i++ { + a := args[i] + if a == '--' { + operands << args[i + 1..] + break + } + if a == '--help' { + print_help() + exit(0) + } + if a == '--version' { + println('realpath (V coreutils) ${common.version}') + exit(0) + } + if !a.starts_with('-') || a == '-' { + operands << a + continue + } + + if a.starts_with('--') { + name := a.all_before('=') + val := a.all_after('=') + has_val := a.contains('=') + match name { + '--canonicalize-existing' { + s.must_exist = true + } + '--canonicalize-missing' { + s.allow_missing = true + } + '--no-symlinks' { + s.no_symlinks = true + } + '--zero' { + s.zero_terminated = true + } + '--quiet' { + s.quiet = true + } + '--relative-to' { + v, next := take_value(args, i, val, has_val, name) + i = next + s.relative_to = v + } + '--relative-base' { + v, next := take_value(args, i, val, has_val, name) + i = next + s.relative_base = v + } + '--logical' { + s.no_symlinks = true + } + '--physical' {} + else { + common.exit_with_error_message('realpath', "unrecognized option '${a}'") + } + } + continue + } + + mut j := 1 + for j < a.len { + c := a[j] + match c { + `e` { + s.must_exist = true + } + `m` { + s.allow_missing = true + } + `s` { + s.no_symlinks = true + } + `z` { + s.zero_terminated = true + } + `q` { + s.quiet = true + } + `L` { + s.no_symlinks = true + } + `P` {} + `-` { + j++ + continue + } + else { + common.exit_with_error_message('realpath', "invalid option -- '${c.ascii_str()}'") + } + } + j++ + } + } + + s.operands = operands + return s +} + +fn take_value(args []string, i int, inline_val string, has_val bool, name string) (string, int) { + if has_val { + return inline_val, i + } + if i + 1 >= args.len { + common.exit_with_error_message('realpath', "option '${name}' requires an argument") + } + return args[i + 1], i + 1 +} + +fn print_help() { + println('Usage: realpath [OPTION]... FILE...') + println('Print the resolved absolute file name; all but the last component must exist') + println('') + println(' -e, --canonicalize-existing all components of the path must exist') + println(' -m, --canonicalize-missing no path components need exist') + println(' -L, --logical resolve .. components before symlinks') + println(' -P, --physical resolve symlinks as encountered (default)') + println(' -q, --quiet suppress most error messages') + println(' --relative-to=DIR print the resolved path relative to DIR') + println(' --relative-base=DIR print absolute paths unless paths below DIR') + println(' -s, --strip, --no-symlinks do not expand symlinks') + println(' -z, --zero end each output line with NUL, not newline') + println(' --help display this help and exit') + println(' --version output version information and exit') +} diff --git a/common/realpath/realpath.v b/common/realpath/realpath.v new file mode 100644 index 00000000..c8694c26 --- /dev/null +++ b/common/realpath/realpath.v @@ -0,0 +1,298 @@ +module realpath + +import os + +pub struct Settings { +pub mut: + must_exist bool // -e: every component must exist + allow_missing bool // -m: no component needs to exist + no_symlinks bool // -s: canonicalise without resolving links + zero_terminated bool // -z + quiet bool // -q + relative_to string + relative_base string + operands []string +} + +// resolve turns one operand into the text GNU would print for it. +pub fn resolve(path string, set Settings) !string { + if path == '' { + return error_with_code('', 1) + } + mut abs := absolute(path) + + if !set.no_symlinks { + abs = canonical(abs) + } else { + abs = lexical(abs) + } + abs = strip_trailing(abs) + + check_exists(abs, path, set)! + + mut out := '' + if set.relative_base != '' { + base := canonical(absolute(set.relative_base)) + if under(abs, base) { + out = relative_from(abs, base) + } else { + // Not both under the base, so the resolved path is printed as-is. + out = abs + } + } else if set.relative_to != '' { + base := if set.allow_missing { + lexical(absolute(set.relative_to)) + } else { + canonical(absolute(set.relative_to)) + } + out = relative_from(abs, base) + } else { + out = abs + } + return native_separators(out) +} + +// native_separators gives a Windows caller back the backslash form it +// expects, matching what uutils and the Windows shell produce. +fn native_separators(p string) string { + $if windows { + return p.replace('/', '\\') + } $else { + return p + } +} + +// absolute makes path absolute without resolving anything in it. +fn absolute(path string) string { + mut p := path.replace('\\', '/') + if p.len > 1 && p[1] == `:` { + // Already drive-qualified on Windows. + if p.len == 2 { + p += '/' + } + return p + } + if p.starts_with('/') { + // A root-relative path on Windows still needs a drive letter. + cwd := os.getwd().replace('\\', '/') + drive := if cwd.len > 1 && cwd[1] == `:` { cwd[..2] } else { '/' } + return drive + p + } + mut cwd := os.getwd().replace('\\', '/') + for cwd.len > 1 && cwd.ends_with('/') { + cwd = cwd[..cwd.len - 1] + } + return cwd + '/' + p +} + +// lexical collapses '.' and '..' textually, without touching the filesystem. +fn lexical(p string) string { + parts := split_parts(p) + mut out := []string{} + for c in parts { + if c == '.' { + continue + } + if c == '..' { + if out.len > 0 { + out.delete_last() + } + continue + } + out << c + } + root, _ := root_and_body(p) + mut s := root + for c in out { + s += c + s += '/' + } + if s.len > root.len { + s = s[..s.len - 1] + } + return s +} + +// canonical resolves symbolic links by repeatedly resolving the longest +// prefix that exists, then continuing into whatever is left. os.real_path is +// used because V's standard library exposes no read_link, and it needs the +// path to exist, which is why the walk stops at the first missing component. +fn canonical(p string) string { + mut cur := lexical(p) + for { + if !os.exists(cur) { + // Resolve the deepest ancestor that does exist, then re-attach. + parts := split_parts(cur) + root, _ := root_and_body(cur) + mut prefix := root + mut i := 0 + for i < parts.len { + candidate := if prefix == root { root + parts[i] } else { prefix + '/' + parts[i] } + if os.exists(candidate) { + prefix = candidate + i++ + } else { + break + } + } + if prefix == cur { + return cur + } + // The remainder is collapsed lexically, since nothing there is a + // link that can be followed. + mut tail := '' + for j := i; j < parts.len; j++ { + tail += parts[j] + tail += '/' + } + if tail.len > 0 { + tail = tail[..tail.len - 1] + } + cur = if tail == '' { prefix } else { prefix + '/' + tail } + return lexical(cur) + } + // The path exists, so ask the OS to resolve it and repeat until the + // answer stops moving. + real := os.real_path(cur).replace('\\', '/') + if real == '' { + return cur + } + real_lex := lexical(real) + if real_lex == cur { + return cur + } + cur = real_lex + } + return cur +} + +fn strip_trailing(p string) string { + mut s := p + for s.len > 1 && s.ends_with('/') { + s = s[..s.len - 1] + } + return s +} + +fn split_parts(p string) []string { + mut s := p.replace('\\', '/') + mut body := s + if s.len > 1 && s[1] == `:` { + body = s[2..] + } else if s.starts_with('//') { + // UNC: keep the whole prefix as the root. + mut parts := s.split('/') + mut i := 0 + for i < parts.len && parts[i] == '' { + i++ + } + if i + 1 < parts.len { + return parts[i + 2..] + } + return []string{} + } + mut out := []string{} + for c in body.split('/') { + if c != '' { + out << c + } + } + return out +} + +// root_and_body returns the leading separator prefix and the path after it. +fn root_and_body(p string) (string, []string) { + s := p.replace('\\', '/') + if s.starts_with('//') { + parts := s.split('/') + mut i := 0 + for i < parts.len && parts[i] == '' { + i++ + } + if i + 1 < parts.len { + return '/' + parts[i] + '/' + parts[i + 1], parts[i + 2..] + } + } + if s.len > 1 && s[1] == `:` { + return s[..2] + '/', split_parts(s) + } + if s.starts_with('/') { + return '/', split_parts(s) + } + return '', split_parts(s) +} + +// check_exists enforces the -e / -m / default existence rules. The default +// requires every component but the last to exist, which is why a missing +// final name is fine and a missing directory in the middle is not. +fn check_exists(abs string, original string, set Settings) ! { + if set.allow_missing { + return + } + root, parts := root_and_body(abs) + mut prefix := root + for i, c in parts { + is_last := i == parts.len - 1 + prefix = if prefix == root { root + c } else { prefix + '/' + c } + if os.exists(prefix) { + continue + } + if is_last && !set.must_exist { + return + } + if set.quiet { + exit(0) + } + return error_with_posix('${original}: No such file or directory', 1) + } +} + +// under reports whether path sits below base. The character after the shared +// prefix has to be a separator, so /usr/local/bin is not below /usr/loc. It is +// public so this boundary is testable without a matching filesystem. +pub fn under(path string, base string) bool { + if base == '' { + return false + } + if !path.starts_with(base) { + return false + } + if path.len == base.len { + return true + } + next := path[base.len] + return next == `/` || next == `\\` +} + +// relative_from expresses target relative to base, using '..' where the two +// diverge. It is public so the arithmetic can be tested without a filesystem. +pub fn relative_from(target string, base string) string { + t := strip_trailing(target) + b := strip_trailing(base) + if t == b { + return '.' + } + mut tp := split_parts(t) + mut bp := split_parts(b) + mut common := 0 + for common < tp.len && common < bp.len && tp[common] == bp[common] { + common++ + } + mut out := []string{} + for _ in 0 .. (bp.len - common) { + out << '..' + } + for i := common; i < tp.len; i++ { + out << tp[i] + } + if out.len == 0 { + return '.' + } + return out.join('/') +} + +// error_with_posix produces the diagnostic GNU prints, tagged so the caller +// can tell an operand failure (exit 1) from a success. +fn error_with_posix(msg string, code int) IError { + return error_with_code(msg, code) +} diff --git a/src/realpath/realpath.v b/src/realpath/realpath.v new file mode 100644 index 00000000..c9cceebe --- /dev/null +++ b/src/realpath/realpath.v @@ -0,0 +1,29 @@ +import common.realpath as realpathmod +import os + +fn main() { + set := realpathmod.parse_args(os.args[1..]) + if set.operands.len == 0 { + eprintln('realpath: missing operand') + eprintln("Try 'realpath --help' for more information.") + exit(1) + } + mut code := 0 + for operand in set.operands { + out := realpathmod.resolve(operand, set) or { + if err.code() != 0 { + code = 1 + } + if !set.quiet { + eprintln('realpath: ${err.msg()}') + } + continue + } + if set.zero_terminated { + print(out + u8(0).ascii_str()) + } else { + println(out) + } + } + exit(code) +} diff --git a/src/realpath/realpath_test.v b/src/realpath/realpath_test.v new file mode 100644 index 00000000..39040783 --- /dev/null +++ b/src/realpath/realpath_test.v @@ -0,0 +1,147 @@ +import common.realpath as realpathmod +import common.testing +import os + +const rig = testing.prepare_rig(util: 'realpath') + +const repo = os.join_path(os.getwd(), '..', '..') + +struct Run { +mut: + code int + stdout string + stderr string +} + +fn run_tr(args []string) Run { + mut p := os.new_process(rig.executable_under_test) + p.set_args(args) + p.set_redirect_stdio() + p.wait() + r := Run{ + code: p.code + stdout: p.stdout_slurp() + stderr: p.stderr_slurp() + } + p.close() + return r +} + +fn run_uu(args []string) Run { + mut p := os.new_process(rig.platform_util_path) + mut full_args := ['realpath'] + full_args << args + p.set_args(full_args) + p.set_redirect_stdio() + p.wait() + r := Run{ + code: p.code + stdout: p.stdout_slurp() + stderr: p.stderr_slurp() + } + p.close() + return r +} + +fn assert_same(args []string) { + tr := run_tr(args) + uu := run_uu(args) + assert tr.code == uu.code, 'exit code for [${args}]: tr=${tr.code} uu=${uu.code}' + assert tr.stdout == uu.stdout, 'stdout for [${args}]: tr=${tr.stdout} uu=${uu.stdout}' +} + +fn testsuite_begin() { + rig.assert_platform_util() +} + +fn test_help_and_version() { + rig.assert_help_and_version_options_work() +} + +fn test_matches_reference_absolute_and_relative() { + assert_same([repo]) + assert_same(['.']) + assert_same([os.join_path(repo, '.')]) + assert_same([os.join_path(repo, '..', 'coreutils')]) +} + +fn test_matches_reference_missing_paths() { + assert_same(['C:\\nope\\x']) + assert_same(['-e', 'C:\\nope']) + assert_same(['-m', 'C:\\nope\\x\\y']) + assert_same(['-q', 'C:\\nope\\x']) +} + +fn test_matches_reference_no_symlinks() { + assert_same(['-s', os.join_path(repo, '..', 'coreutils')]) + assert_same(['--no-symlinks', repo]) +} + +fn test_matches_reference_relative_options() { + assert_same(['--relative-to=C:\\Users\\MR\\Projects', repo]) + assert_same(['--relative-to', 'C:\\Users\\MR', repo]) + assert_same(['--relative-base=C:\\Users\\MR\\Projects', repo]) + assert_same(['--relative-base=C:\\Users', 'C:\\nope\\deep']) +} + +fn test_matches_reference_multiple_operands() { + assert_same([repo, os.join_path(repo, '..', 'coreutils')]) +} + +fn test_parse_args_flags() { + set := realpathmod.parse_args(['-e', '-m', '-s', '-z', '-q', 'x']) + assert set.must_exist + assert set.allow_missing + assert set.no_symlinks + assert set.zero_terminated + assert set.quiet + assert set.operands == ['x'] +} + +fn test_parse_args_long_forms() { + e := realpathmod.parse_args(['--canonicalize-existing', '--canonicalize-missing', '--no-symlinks', + '--zero', '--quiet', 'a', 'b']) + assert e.must_exist + assert e.allow_missing + assert e.no_symlinks + assert e.zero_terminated + assert e.quiet + assert e.operands == ['a', 'b'] +} + +fn test_parse_args_separator() { + set := realpathmod.parse_args(['--', '-e']) + assert set.operands == ['-e'] + assert !set.must_exist +} + +fn test_parse_args_relative_value_forms() { + a := realpathmod.parse_args(['--relative-to=/usr', 'x']) + assert a.relative_to == '/usr' + + b := realpathmod.parse_args(['--relative-to', '/usr', 'x']) + assert b.relative_to == '/usr' + + c := realpathmod.parse_args(['--relative-base=/usr', 'x']) + assert c.relative_base == '/usr' +} + +fn test_relative_from_diverging_paths() { + assert realpathmod.relative_from('/usr/local/bin', '/usr') == 'local/bin' + assert realpathmod.relative_from('/etc/passwd', '/usr') == '../etc/passwd' + assert realpathmod.relative_from('/usr', '/usr') == '.' + assert realpathmod.relative_from('/a/b/c', '/a/b') == 'c' +} + +fn test_under_requires_a_separator_boundary() { + // A base that is a strict string prefix is not an ancestor, so the + // absolute path must be printed instead of a bogus relative one. + assert realpathmod.under('/usr/local', '/usr/local') + assert realpathmod.under('/usr/local/bin', '/usr') + assert realpathmod.under('C:/Users/MR/x', 'C:/Users/MR') + assert !realpathmod.under('/usr/local/bin', '/usr/loc') + assert !realpathmod.under('/usr/local/bin', '/usr/local/bin/deeper') + assert !realpathmod.under('/etc/passwd', '/usr') + assert !realpathmod.under('/usrx', '/usr') + assert !realpathmod.under('/usr/local/bin', '') +}