Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
280 changes: 251 additions & 29 deletions internal/vm/dir.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ package vm

import (
"os"
"path" // always '/'-separated, as Ruby's Dir is
"sort"
"strings"

"github.com/go-embedded-ruby/ruby/internal/object"
Expand Down Expand Up @@ -42,15 +40,29 @@ func (vm *VM) registerDir() {
}
return object.NewArrayFromSlice(elems)
})
glob := func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value {
var elems []object.Value
for _, m := range dirGlob(strArg(args[0])) {
elems = append(elems, object.NewString(m))
// Dir.glob(pattern, flags=0, base: nil, sort: true) — pattern is a String or
// an Array of Strings; flags is an FNM_* OR (positional, or the `flags:`
// keyword). With a block it yields each match and returns nil.
def("glob", func(vm *VM, _ object.Value, args []object.Value, blk *Proc) object.Value {
base, sortR, flags, pos := parseGlobArgs(vm, args)
if len(pos) < 1 || len(pos) > 2 {
raise("ArgumentError", "wrong number of arguments (given %d, expected 1..2)", len(pos))
}
return object.NewArrayFromSlice(elems)
}
def("glob", glob)
def("[]", glob)
if len(pos) == 2 {
flags = int(intArg(pos[1]))
}
return globResult(vm, globPatternArg(vm, pos[0]), flags, base, sortR, blk)
})
// Dir.[](*patterns, base: nil, sort: true) — like glob but takes one or more
// pattern arguments and no flags/block.
def("[]", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value {
base, sortR, _, pos := parseGlobArgs(vm, args)
var patterns []string
for _, a := range pos {
patterns = append(patterns, globPatternArg(vm, a)...)
}
return globResult(vm, patterns, 0, base, sortR, nil)
})
def("exist?", dirExist)
def("exists?", dirExist)
def("empty?", func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value {
Expand Down Expand Up @@ -285,30 +297,240 @@ func dirNames(p string) []string {
return names
}

// dirGlob matches a single-level shell pattern (e.g. "dir/*.rb"), returning the
// sorted matches with the pattern's directory prefix preserved. Hidden entries
// are skipped unless the pattern's basename is itself explicit about the dot.
func dirGlob(pattern string) []string {
prefix, base := path.Split(pattern)
readDir := strings.TrimSuffix(prefix, "/")
switch readDir {
case "":
readDir = "."
// parseGlobArgs splits a Dir.glob / Dir.[] argument list into its keyword options
// (base:, sort:, flags:) and the remaining positional arguments. base defaults to
// the current directory (""), sort to true and flags to 0, matching MRI.
func parseGlobArgs(vm *VM, args []object.Value) (base string, sortR bool, flags int, pos []object.Value) {
sortR = true
pos = args
if n := len(args); n > 0 {
if h, ok := args[n-1].(*object.Hash); ok {
pos = args[:n-1]
if v, ok := h.Get(object.Symbol("base")); ok && v != object.NilV {
base = pathArg(vm, v)
}
if v, ok := h.Get(object.Symbol("sort")); ok {
sortR = v.Truthy()
}
if v, ok := h.Get(object.Symbol("flags")); ok && v != object.NilV {
flags = int(intArg(v))
}
}
}
entries, err := os.ReadDir(readDir)
if err != nil {
return nil
return
}

// globPatternArg coerces a Dir.glob pattern argument — a String or an Array of
// Strings — into a slice of pattern strings.
func globPatternArg(vm *VM, v object.Value) []string {
if arr, ok := v.(*object.Array); ok {
out := make([]string, len(arr.Elems))
for i, e := range arr.Elems {
out[i] = strArg(e)
}
return out
}
return []string{pathArg(vm, v)}
}

// globResult runs every pattern, coalesces and (by default) sorts the matches,
// then either yields each to a block (returning nil) or returns them as an Array.
func globResult(vm *VM, patterns []string, flags int, base string, sortR bool, blk *Proc) object.Value {
var matches []string
for _, e := range entries {
name := e.Name()
if strings.HasPrefix(name, ".") && !strings.HasPrefix(base, ".") {
for _, pat := range patterns {
matches = append(matches, globPattern(pat, base, flags)...)
}
matches = sortedUnique(matches, sortR)
if blk != nil {
for _, m := range matches {
vm.callBlock(blk, []object.Value{object.NewString(m)})
}
return object.NilV
}
elems := make([]object.Value, len(matches))
for i, m := range matches {
elems[i] = object.NewString(m)
}
return object.NewArrayFromSlice(elems)
}

// globPattern returns the existing filesystem paths under base that match one
// glob pattern, expanding '{a,b}' braces first (glob always honours braces) and
// walking each resulting pattern against the real directory tree.
func globPattern(pat, base string, flags int) []string {
var out []string
for _, expanded := range braceExpand(pat, flags&fnmNoEscape == 0) {
globExpanded(expanded, base, flags, &out)
}
return out
}

// globExpanded walks one brace-free pattern. It resolves the start directory
// (base, or '/' for an absolute pattern), records whether a trailing '/' restricts
// matches to directories, and delegates the segment walk to globWalk.
func globExpanded(pat, base string, flags int, out *[]string) {
segs := strings.Split(pat, "/")
fsDir, outPrefix := base, ""
if fsDir == "" {
fsDir = "."
}
if len(segs) > 0 && segs[0] == "" { // absolute pattern
fsDir, outPrefix = "/", "/"
segs = segs[1:]
}
dirOnly := false
for len(segs) > 0 && segs[len(segs)-1] == "" { // trailing '/': directories only
dirOnly = true
segs = segs[:len(segs)-1]
}
if len(segs) == 0 {
if outPrefix == "/" && isDirFS("/") { // the pattern was "/" (or "//…")
*out = append(*out, "/")
}
return
}
globWalk(fsDir, outPrefix, segs, dirOnly, flags, out)
}

// globWalk matches the remaining pattern segments against the directory fsDir,
// appending each existing match (with the Ruby-slash outPrefix) to out. A '**'
// segment that is not final recurses into subdirectories (skipping hidden ones
// unless FNM_DOTMATCH); a literal segment is resolved by a direct stat so '.',
// '..' and explicit hidden names work; other segments are matched against the
// directory's entries, with a synthetic '.' offered for a terminal segment so
// MRI's inclusion of "." under a matching pattern is reproduced.
func globWalk(fsDir, outPrefix string, segs []string, dirOnly bool, flags int, out *[]string) {
period := flags&fnmDotMatch == 0
nocase := flags&fnmCaseFold != 0
escape := flags&fnmNoEscape == 0

seg, rest := segs[0], segs[1:]
isLast := len(rest) == 0

// '**' recurses when it is not the final segment, and also when it is the final
// segment of a directory-only pattern ('a/**/'), where each level it reaches is
// itself a match. A plain trailing '**' (no slash) instead behaves like '*'.
if seg == "**" && (!isLast || dirOnly) {
if isLast { // dirOnly: the directory itself matches (zero levels)
if outPrefix != "" && isDirFS(fsDir) {
*out = append(*out, outPrefix)
}
} else {
globWalk(fsDir, outPrefix, rest, dirOnly, flags, out) // rest matches here (zero levels)
}
for _, name := range readDirNames(fsDir) {
if period && strings.HasPrefix(name, ".") {
continue // do not recurse into a hidden directory without DOTMATCH
}
if child := fsJoin(fsDir, name); isDirFS(child) {
globWalk(child, outPrefix+name+"/", segs, dirOnly, flags, out)
}
}
return
}

matchSeg := seg
if seg == "**" { // a plain trailing '**' behaves like '*'
matchSeg = "*"
}

// A metacharacter-free segment is resolved by a direct stat (matching MRI and
// letting '.', '..' and explicit hidden names through unconditionally).
if lit, ok := literalSegment(matchSeg, escape); ok {
globEmit(fsDir, outPrefix, lit, rest, isLast, dirOnly, flags, out)
return
}

names := readDirNames(fsDir)
if isLast {
names = append(names, ".") // MRI matches "." (never "..") for a terminal segment
}
for _, name := range names {
if !matchSegment(matchSeg, name, escape, nocase, period) {
continue
}
if ok, _ := path.Match(base, name); ok {
matches = append(matches, prefix+name)
globEmit(fsDir, outPrefix, name, rest, isLast, dirOnly, flags, out)
}
}

// globEmit records a matched entry name: as a terminal result (honouring the
// directory-only trailing slash) when no pattern segments remain, or by recursing
// into it when it is an existing directory and more segments follow.
func globEmit(fsDir, outPrefix, name string, rest []string, isLast, dirOnly bool, flags int, out *[]string) {
matchedPath := outPrefix + name
if isLast {
full := fsJoin(fsDir, name)
if dirOnly {
if isDirFS(full) {
*out = append(*out, matchedPath+"/")
}
return
}
if fsExists(full) {
*out = append(*out, matchedPath)
}
return
}
sort.Strings(matches)
return matches
if child := fsJoin(fsDir, name); isDirFS(child) {
globWalk(child, matchedPath+"/", rest, dirOnly, flags, out)
}
}

// literalSegment reports whether seg is free of the glob metacharacters '*', '?'
// and '[' (after honouring '\' escapes), returning the unescaped literal text
// when so — used to resolve a segment by a direct stat.
func literalSegment(seg string, escape bool) (string, bool) {
var b strings.Builder
for i := 0; i < len(seg); i++ {
c := seg[i]
if escape && c == '\\' && i+1 < len(seg) {
b.WriteByte(seg[i+1])
i++
continue
}
if c == '*' || c == '?' || c == '[' {
return "", false
}
b.WriteByte(c)
}
return b.String(), true
}

// readDirNames returns the entry names of dir (no "." / ".."), or nil when dir is
// not a readable directory — an unreadable directory simply yields no matches.
func readDirNames(dir string) []string {
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
names := make([]string, len(entries))
for i, e := range entries {
names[i] = e.Name()
}
return names
}

// fsJoin joins a directory and an entry name into a filesystem path, treating ""
// and "." as the current directory and "/" as the root.
func fsJoin(dir, name string) string {
switch dir {
case "", ".":
return name
case "/":
return "/" + name
default:
return dir + "/" + name
}
}

// isDirFS reports whether p is an existing directory.
func isDirFS(p string) bool {
fi, err := os.Stat(p)
return err == nil && fi.IsDir()
}

// fsExists reports whether p exists (file, directory or otherwise).
func fsExists(p string) bool {
_, err := os.Lstat(p)
return err == nil
}
23 changes: 23 additions & 0 deletions internal/vm/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,31 @@ func (vm *VM) registerFile() {
for name, val := range fileFlagConsts {
cFile.consts[name] = object.IntValue(val)
}
// File::FNM_* flag constants for File.fnmatch? / Dir.glob (values match MRI).
// FNM_SYSCASE is 0 on the case-sensitive POSIX platforms the gate runs on.
for name, val := range map[string]int64{
"FNM_NOESCAPE": fnmNoEscape, "FNM_PATHNAME": fnmPathname, "FNM_DOTMATCH": fnmDotMatch,
"FNM_CASEFOLD": fnmCaseFold, "FNM_EXTGLOB": fnmExtGlob, "FNM_SYSCASE": fnmSysCase,
} {
cFile.consts[name] = object.IntValue(val)
}
def := func(name string, fn NativeFn) { cFile.smethods[name] = &Method{name: name, owner: cFile, native: fn} }

// File.fnmatch?(pattern, path, flags=0) and its alias File.fnmatch test path
// against a shell glob pattern, returning true/false. flags is an FNM_* OR.
fnmatchFn := func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value {
if len(args) < 2 || len(args) > 3 {
raise("ArgumentError", "wrong number of arguments (given %d, expected 2..3)", len(args))
}
flags := 0
if len(args) == 3 {
flags = int(intArg(args[2]))
}
return object.Bool(fnmatch(strArg(args[0]), pathArg(vm, args[1]), flags))
}
def("fnmatch?", fnmatchFn)
def("fnmatch", fnmatchFn)

def("basename", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value {
if len(args) < 1 || len(args) > 2 {
raise("ArgumentError", "wrong number of arguments (given %d, expected 1..2)", len(args))
Expand Down
Loading
Loading