Skip to content

Commit

Permalink
Implement os:stat.
Browse files Browse the repository at this point in the history
Time fields are omitted for now; they will be added when there's a proper Elvish
binding for time.Time.

This addresses #1659.
  • Loading branch information
xiaq committed Aug 21, 2023
1 parent 65bc958 commit 607fa68
Show file tree
Hide file tree
Showing 14 changed files with 495 additions and 32 deletions.
43 changes: 43 additions & 0 deletions pkg/eval/evaltest/value_matchers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package evaltest

import (
"math"
"math/big"
"regexp"

"src.elv.sh/pkg/eval/vals"
)

// ValueMatcher is a value that can be passed to [Case.Puts] and has its own
Expand All @@ -17,6 +20,20 @@ type anything struct{}

func (anything) matchValue(any) bool { return true }

// AnyInteger matches any integer.
var AnyInteger ValueMatcher = anyInteger{}

type anyInteger struct{}

func (anyInteger) matchValue(x any) bool {
switch x.(type) {
case int, *big.Int:
return true
default:
return false
}
}

// ApproximatelyThreshold defines the threshold for matching float64 values when
// using [Approximately].
const ApproximatelyThreshold = 1e-15
Expand Down Expand Up @@ -57,3 +74,29 @@ func (s stringMatching) matchValue(value any) bool {
}
return false
}

// MapContaining matches any map that contains all the key-value pairs in the
// given map. The values in the argument itself can also be [ValueMatcher]s.
func MapContaining(m vals.Map) ValueMatcher { return mapContaining{m} }

// MapContainingPairs is a shorthand for MapContaining(vals.MakeMap(a...)).
func MapContainingPairs(a ...any) ValueMatcher { return MapContaining(vals.MakeMap(a...)) }

type mapContaining struct{ m vals.Map }

func (m mapContaining) matchValue(value any) bool {
if gotMap, ok := value.(vals.Map); ok {
for it := m.m.Iterator(); it.HasElem(); it.Next() {
k, wantValue := it.Elem()
if gotValue, ok := gotMap.Index(k); !ok || !match(gotValue, wantValue) {
return false
}
}
return true
}
return false
}

func (m mapContaining) Repr(indent int) string {
return vals.Repr(m.m, indent)
}
49 changes: 49 additions & 0 deletions pkg/mods/os/os.d.elv
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,55 @@ fn remove-all {|path| }
# ```
fn eval-symlinks {|path| }

# Describes the file at `path` by writing a map with the following fields:
#
# - `name`: The base name of the file.
#
# - `size`: Length in bytes for regular files; system-dependent for others.
#
# - `type`: One of `regular`, `dir`, `symlink`, `named-pipe`, `socket`,
# `device`, `char-device` and `irregular`.
#
# - `perm`: Permission bits of the file following Unix's convention.
#
# See [numeric notation of Unix
# permission](https://en.wikipedia.org/wiki/File-system_permissions#Numeric_notation)
# for a description of the convention, but note that Elvish prints this number
# in decimal; use [`printf`](builtin.html#printf) with `%o` to print it as an octal number.
#
# - `special-modes`: A list containing one or more of `setuid`, `setgid` and
# `sticky` to indicate the presence of any special mode.
#
# - `sys`: System-dependent information:
#
# - On Unix, a map that corresponds 1:1 to the `stat_t` struct, except that
# timestamp fields are not exposed yet.
#
# - On Windows, a map that currently contains just one field,
# `file-attributes`, which is a list describing which [file attribute
# fields](https://learn.microsoft.com/en-us/windows/win32/fileio/file-attribute-constants)
# are set. For example, if `FILE_ATTRIBUTE_READONLY` is set, the list
# contains `readonly`.
#
# See [`follow-symlink`](#follow-symlink) for an explanation of the option.
#
# Examples:
#
# ```elvish-transcript
# ~> echo content > regular
# ~> os:stat regular
# ▶ [&name=regular &perm=(num 420) &size=(num 8) &special-modes=[] &sys=[&...] &type=regular]
# ~> mkdir dir
# ~> os:stat dir
# ▶ [&name=dir &perm=(num 493) &size=(num 96) &special-modes=[] &sys=[&...] &type=dir]
# ~> ln -s dir symlink
# ~> os:stat symlink
# ▶ [&name=symlink &perm=(num 493) &size=(num 3) &special-modes=[] &sys=[&...] &type=symlink]
# ~> os:stat &follow-symlink symlink
# ▶ [&name=symlink &perm=(num 493) &size=(num 96) &special-modes=[] &sys=[&...] &type=dir]
# ```
fn stat {|&follow-symlink=$false path| }

# Reports whether a file is known to exist at `path`.
#
# See [`follow-symlink`](#follow-symlink) for an explanation of the option.
Expand Down
28 changes: 19 additions & 9 deletions pkg/mods/os/os.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"src.elv.sh/pkg/eval"
"src.elv.sh/pkg/eval/errs"
"src.elv.sh/pkg/eval/vals"
"src.elv.sh/pkg/eval/vars"
)

Expand All @@ -27,6 +28,7 @@ var Ns = eval.BuildNsNamed("os").

"eval-symlinks": filepath.EvalSymlinks,

"stat": stat,
"exists": exists,
"is-dir": IsDir,
"is-regular": IsRegular,
Expand Down Expand Up @@ -87,30 +89,38 @@ func removeAll(path string) error {
return os.RemoveAll(path)
}

type statPredOpts struct{ FollowSymlink bool }
type statOpts struct{ FollowSymlink bool }

func (opts *statPredOpts) SetDefaultOptions() {}
func (opts *statOpts) SetDefaultOptions() {}

func exists(opts statPredOpts, path string) bool {
_, err := stat(path, opts.FollowSymlink)
func stat(opts statOpts, path string) (vals.Map, error) {
fi, err := statOrLstat(path, opts.FollowSymlink)
if err != nil {
return nil, err
}
return statMap(fi), nil
}

func exists(opts statOpts, path string) bool {
_, err := statOrLstat(path, opts.FollowSymlink)
return err == nil
}

// IsDir is exported so that the implementation may be shared by the path:
// module.
func IsDir(opts statPredOpts, path string) bool {
fi, err := stat(path, opts.FollowSymlink)
func IsDir(opts statOpts, path string) bool {
fi, err := statOrLstat(path, opts.FollowSymlink)
return err == nil && fi.Mode().IsDir()
}

// IsRegular is exported so that the implementation may be shared by the path:
// module.
func IsRegular(opts statPredOpts, path string) bool {
fi, err := stat(path, opts.FollowSymlink)
func IsRegular(opts statOpts, path string) bool {
fi, err := statOrLstat(path, opts.FollowSymlink)
return err == nil && fi.Mode().IsRegular()
}

func stat(path string, followSymlink bool) (os.FileInfo, error) {
func statOrLstat(path string, followSymlink bool) (os.FileInfo, error) {
if followSymlink {
return os.Stat(path)
} else {
Expand Down
28 changes: 7 additions & 21 deletions pkg/mods/os/os_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,11 @@ import (

"src.elv.sh/pkg/eval"
"src.elv.sh/pkg/eval/errs"
"src.elv.sh/pkg/eval/evaltest"
"src.elv.sh/pkg/mods/file"
osmod "src.elv.sh/pkg/mods/os"
"src.elv.sh/pkg/testutil"
)

var (
Test = evaltest.Test
TestWithEvalerSetup = evaltest.TestWithEvalerSetup
TestWithSetup = evaltest.TestWithSetup
Use = evaltest.Use
That = evaltest.That
StringMatching = evaltest.StringMatching
ErrorWithType = evaltest.ErrorWithType
ErrorWithMessage = evaltest.ErrorWithMessage
)

var useOS = Use("os", osmod.Ns)

func TestFSModifications(t *testing.T) {
// Also tests -is-exists and -is-not-exists
TestWithSetup(t, func(t *testing.T, ev *eval.Evaler) {
Expand Down Expand Up @@ -60,15 +46,15 @@ func TestFSModifications(t *testing.T) {
)
}

var testDir = testutil.Dir{
"d": testutil.Dir{
var testDir = Dir{
"d": Dir{
"f": "",
},
}

func TestFilePredicates(t *testing.T) {
tmpdir := testutil.InTempDir(t)
testutil.ApplyDir(testDir)
tmpdir := InTempDir(t)
ApplyDir(testDir)

TestWithEvalerSetup(t, useOS,
That("os:exists "+tmpdir).Puts(true),
Expand Down Expand Up @@ -99,8 +85,8 @@ var symlinks = []struct {
}

func TestFilePredicates_Symlinks(t *testing.T) {
testutil.InTempDir(t)
testutil.ApplyDir(testDir)
InTempDir(t)
ApplyDir(testDir)
for _, link := range symlinks {
err := os.Symlink(link.target, link.path)
if err != nil {
Expand Down Expand Up @@ -150,7 +136,7 @@ func TestFilePredicates_Symlinks(t *testing.T) {
var anyDir = "^.*" + regexp.QuoteMeta(string(filepath.Separator))

func TestTempDirFile(t *testing.T) {
testutil.InTempDir(t)
InTempDir(t)

TestWithEvalerSetup(t, Use("os", osmod.Ns, "file", file.Ns),
That("var x = (os:temp-dir)", "rmdir $x", "put $x").Puts(
Expand Down
57 changes: 57 additions & 0 deletions pkg/mods/os/stat.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package os

import (
"fmt"
"io/fs"

"src.elv.sh/pkg/eval/vals"
)

var typeNames = map[fs.FileMode]string{
0: "regular",
fs.ModeDir: "dir",
fs.ModeSymlink: "symlink",
fs.ModeNamedPipe: "named-pipe",
fs.ModeSocket: "socket",
fs.ModeDevice: "device",
fs.ModeDevice | fs.ModeCharDevice: "char-device",
fs.ModeIrregular: "irregular",
}

var specialModeNames = [...]struct {
bit fs.FileMode
name string
}{
// fs.ModeAppend, fs.ModeExclusive and fs.ModeTemporary are only used on
// Plan 9, which Elvish doesn't support (yet).
{fs.ModeSetuid, "setuid"},
{fs.ModeSetgid, "setgid"},
{fs.ModeSticky, "sticky"},
}

// Implementation of the stat function itself is in os.go.

func statMap(fi fs.FileInfo) vals.Map {
mode := fi.Mode()
typeName, ok := typeNames[mode.Type()]
if !ok {
// This shouldn't happen, but if there is a bug this gives us a bit of
// information.
typeName = fmt.Sprintf("unknown %d", mode.Type())
}
// TODO: Make this a set when Elvish has a set type.
specialModes := vals.EmptyList
for _, special := range specialModeNames {
if mode&special.bit != 0 {
specialModes = specialModes.Conj(special.name)
}
}
return vals.MakeMap(
"name", fi.Name(),
"size", vals.Int64ToNum(fi.Size()),
"type", typeName,
"perm", int(fi.Mode()&fs.ModePerm),
"special-modes", specialModes,
"sys", statSysMap(fi.Sys()))
// TODO: ModTime
}
9 changes: 9 additions & 0 deletions pkg/mods/os/stat_gen.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//go:build darwin || freebsd || netbsd || openbsd

package os

import "syscall"

func init() {
extraStatFields["gen"] = func(st *syscall.Stat_t) uint64 { return uint64(st.Gen) }
}
75 changes: 75 additions & 0 deletions pkg/mods/os/stat_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package os_test

import (
"os"
"testing"

"src.elv.sh/pkg/eval/vals"
"src.elv.sh/pkg/must"
)

func TestStat(t *testing.T) {
InTempDir(t)
ApplyDir(Dir{
"dir": Dir{},
"file": "foobar",
})

TestWithEvalerSetup(t, useOS,
That(`os:stat file`).Puts(MapContainingPairs(
"name", "file",
"size", 6,
"type", "regular",
)),
That(`os:stat dir`).Puts(MapContainingPairs(
"name", "dir",
// size field of directories is platform-dependent
"type", "dir",
)),
That(`os:stat non-existent`).Throws(ErrorWithType(&os.PathError{})),
)
}

func TestStat_Symlink(t *testing.T) {
InTempDir(t)
ApplyDir(Dir{"regular": ""})
err := os.Symlink("regular", "symlink")
if err != nil {
// On Windows we may or may not be able to create a symlink.
t.Skipf("symlink: %v", err)
}

TestWithEvalerSetup(t, useOS,
That(`os:stat symlink`).
Puts(MapContainingPairs("type", "symlink")),
That(`os:stat &follow-symlink=$true symlink`).
Puts(MapContainingPairs("type", "regular")),
)
}

var permAndSpecialModesTests = []struct {
name string
mode os.FileMode
statMap vals.Map
}{
{"444", 0o444, vals.MakeMap("perm", 0o444)},
{"666", 0o666, vals.MakeMap("perm", 0o666)},
{"setuid", os.ModeSetuid, vals.MakeMap("special-modes", vals.MakeList("setuid"))},
{"setgid", os.ModeSetgid, vals.MakeMap("special-modes", vals.MakeList("setgid"))},
{"sticky", os.ModeSticky, vals.MakeMap("special-modes", vals.MakeList("sticky"))},
}

func TestStat_PermAndSpecialModes(t *testing.T) {
Umask(t, 0)
for _, test := range permAndSpecialModesTests {
t.Run(test.name, func(t *testing.T) {
InTempDir(t)
must.OK(os.WriteFile("file", nil, 0o666))
ChmodOrSkip(t, "file", test.mode)

TestWithEvalerSetup(t, useOS,
That(`os:stat file`).Puts(MapContaining(test.statMap)),
)
})
}
}
Loading

0 comments on commit 607fa68

Please sign in to comment.