From 24cc5e620241ab06fb63816ec6c3525846d9c687 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Wed, 25 Mar 2026 16:42:25 +0300 Subject: [PATCH] io.fs: add standard library (fixes #24689) --- vlib/io/fs/README.md | 15 +++ vlib/io/fs/fs.v | 163 ++++++++++++++++++++++++++++++++ vlib/io/fs/fs_test.v | 216 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 394 insertions(+) create mode 100644 vlib/io/fs/README.md create mode 100644 vlib/io/fs/fs.v create mode 100644 vlib/io/fs/fs_test.v diff --git a/vlib/io/fs/README.md b/vlib/io/fs/README.md new file mode 100644 index 000000000..c746ca6b4 --- /dev/null +++ b/vlib/io/fs/README.md @@ -0,0 +1,15 @@ +# io.fs + +`io.fs` defines small filesystem interfaces that can be shared by different +backends and virtual filesystems. + +The module is modeled after Go's `io/fs` package, but follows V naming and type +conventions. + +Current helpers: + +- `valid_path(name string) bool` +- `read_file(filesystem FS, name string) ![]u8` +- `read_dir(filesystem FS, name string) ![]DirEntry` +- `stat(filesystem FS, name string) !FileInfo` +- `file_info_to_dir_entry(info FileInfo) DirEntry` diff --git a/vlib/io/fs/fs.v b/vlib/io/fs/fs.v new file mode 100644 index 000000000..96071ac5f --- /dev/null +++ b/vlib/io/fs/fs.v @@ -0,0 +1,163 @@ +module fs + +import io +import os + +// FileInfo describes a file or directory entry. +pub interface FileInfo { + name() string + size() u64 + mode() os.FileMode + mod_time() i64 + is_dir() bool +} + +// DirEntry describes a single directory entry. +pub interface DirEntry { + name() string + is_dir() bool + typ() os.FileType + info() !FileInfo +} + +// File is the minimum interface required to read from a filesystem entry. +pub interface File { + io.Reader + stat() !FileInfo +mut: + close() +} + +// FS opens slash-separated paths that pass `valid_path`. +pub interface FS { + open(name string) !File +} + +// ReadDirFile can read directory entries directly from an opened file. +pub interface ReadDirFile { + File +mut: + read_dir(n int) ![]DirEntry +} + +// ReadDirFS can read a directory without first opening it as a `File`. +pub interface ReadDirFS { + read_dir(name string) ![]DirEntry +} + +// ReadFileFS can read a whole file without first opening it as a `File`. +pub interface ReadFileFS { + read_file(name string) ![]u8 +} + +// StatFS can stat a path without first opening it as a `File`. +pub interface StatFS { + stat(name string) !FileInfo +} + +// GlobFS can expand glob patterns within a filesystem. +pub interface GlobFS { + glob(pattern string) ![]string +} + +// SubFS can return a filesystem rooted at a subdirectory. +pub interface SubFS { + sub(dir string) !FS +} + +struct FileInfoDirEntry { + source FileInfo +} + +fn (entry FileInfoDirEntry) name() string { + return entry.source.name() +} + +fn (entry FileInfoDirEntry) is_dir() bool { + return entry.source.is_dir() +} + +fn (entry FileInfoDirEntry) typ() os.FileType { + return entry.source.mode().typ +} + +fn (entry FileInfoDirEntry) info() !FileInfo { + return entry.source +} + +fn sort_dir_entries(mut entries []DirEntry) { + entries.sort(a.name() < b.name()) +} + +// valid_path reports whether `name` is a valid slash-separated path for `FS.open`. +pub fn valid_path(name string) bool { + if name == '.' { + return true + } + if name.len == 0 { + return false + } + for elem in name.split('/') { + if elem.len == 0 || elem == '.' || elem == '..' { + return false + } + } + return true +} + +// file_info_to_dir_entry wraps a `FileInfo` in a `DirEntry`. +pub fn file_info_to_dir_entry(info FileInfo) DirEntry { + return FileInfoDirEntry{ + source: info + } +} + +// read_file reads the named file from `filesystem`. +pub fn read_file(filesystem FS, name string) ![]u8 { + if filesystem is ReadFileFS { + reader := filesystem as ReadFileFS + return reader.read_file(name) + } + mut file := filesystem.open(name)! + defer { + file.close() + } + reader := file as io.Reader + return io.read_all( + reader: reader + read_to_end_of_stream: true + ) +} + +// read_dir reads and sorts the named directory from `filesystem`. +pub fn read_dir(filesystem FS, name string) ![]DirEntry { + if filesystem is ReadDirFS { + reader := filesystem as ReadDirFS + mut entries := reader.read_dir(name)! + sort_dir_entries(mut entries) + return entries + } + mut file := filesystem.open(name)! + defer { + file.close() + } + if mut file is ReadDirFile { + mut entries := file.read_dir(-1)! + sort_dir_entries(mut entries) + return entries + } + return error('fs: `${name}` does not support read_dir') +} + +// stat returns file information for the named path from `filesystem`. +pub fn stat(filesystem FS, name string) !FileInfo { + if filesystem is StatFS { + stats := filesystem as StatFS + return stats.stat(name) + } + mut file := filesystem.open(name)! + defer { + file.close() + } + return file.stat() +} diff --git a/vlib/io/fs/fs_test.v b/vlib/io/fs/fs_test.v new file mode 100644 index 000000000..d24353ba2 --- /dev/null +++ b/vlib/io/fs/fs_test.v @@ -0,0 +1,216 @@ +module fs + +import io +import os + +struct MockInfo { + name_ string + size_ u64 + mode_ os.FileMode + mod_time_ i64 +} + +fn (info MockInfo) name() string { + return info.name_ +} + +fn (info MockInfo) size() u64 { + return info.size_ +} + +fn (info MockInfo) mode() os.FileMode { + return info.mode_ +} + +fn (info MockInfo) mod_time() i64 { + return info.mod_time_ +} + +fn (info MockInfo) is_dir() bool { + return info.mode_.typ == .directory +} + +struct MockFile { + data []u8 + info_ MockInfo +mut: + offset int + entries []DirEntry +} + +fn (mut file MockFile) read(mut buf []u8) !int { + if file.offset >= file.data.len { + return io.Eof{} + } + read := copy(mut buf, file.data[file.offset..]) + file.offset += read + return read +} + +fn (file MockFile) stat() !FileInfo { + return file.info_ +} + +fn (mut file MockFile) close() {} + +fn (mut file MockFile) read_dir(n int) ![]DirEntry { + if n <= 0 || n >= file.entries.len { + return file.entries.clone() + } + return file.entries[..n].clone() +} + +struct FallbackFS {} + +fn (filesystem FallbackFS) open(name string) !File { + match name { + 'notes.txt' { + return File(MockFile{ + data: 'fallback'.bytes() + info_: MockInfo{ + name_: 'notes.txt' + size_: u64('fallback'.len) + mode_: os.FileMode{ + typ: .regular + } + mod_time_: 101 + } + }) + } + 'dir' { + return File(MockFile{ + info_: MockInfo{ + name_: 'dir' + mode_: os.FileMode{ + typ: .directory + } + } + entries: [ + file_info_to_dir_entry(MockInfo{ + name_: 'b.txt' + mode_: os.FileMode{ + typ: .regular + } + }), + file_info_to_dir_entry(MockInfo{ + name_: 'a.txt' + mode_: os.FileMode{ + typ: .regular + } + }), + ] + }) + } + else { + return error('missing ${name}') + } + } +} + +struct OptimizedFS {} + +fn (filesystem OptimizedFS) open(name string) !File { + return error('unexpected open for ${name}') +} + +fn (filesystem OptimizedFS) read_file(name string) ![]u8 { + return 'optimized'.bytes() +} + +fn (filesystem OptimizedFS) read_dir(name string) ![]DirEntry { + return [ + file_info_to_dir_entry(MockInfo{ + name_: 'z.txt' + mode_: os.FileMode{ + typ: .regular + } + }), + file_info_to_dir_entry(MockInfo{ + name_: 'm.txt' + mode_: os.FileMode{ + typ: .regular + } + }), + ] +} + +fn (filesystem OptimizedFS) stat(name string) !FileInfo { + return MockInfo{ + name_: name + size_: 42 + mode_: os.FileMode{ + typ: .regular + } + mod_time_: 202 + } +} + +fn entry_names(entries []DirEntry) []string { + mut names := []string{cap: entries.len} + for entry in entries { + names << entry.name() + } + return names +} + +fn test_valid_path() { + assert valid_path('.') + assert valid_path('alpha') + assert valid_path('alpha/beta') + assert valid_path(r'alpha\beta') + assert !valid_path('') + assert !valid_path('/alpha') + assert !valid_path('alpha/') + assert !valid_path('alpha//beta') + assert !valid_path('alpha/./beta') + assert !valid_path('alpha/../beta') +} + +fn test_file_info_to_dir_entry() { + info := MockInfo{ + name_: 'subdir' + size_: 12 + mode_: os.FileMode{ + typ: .directory + } + mod_time_: 303 + } + entry := file_info_to_dir_entry(info) + assert entry.name() == 'subdir' + assert entry.is_dir() + assert entry.typ() == .directory + assert entry.info()!.mod_time() == 303 +} + +fn test_read_file_uses_open_fallback() { + data := read_file(FallbackFS{}, 'notes.txt')! + assert data == 'fallback'.bytes() +} + +fn test_read_file_uses_read_file_fs_when_available() { + data := read_file(OptimizedFS{}, 'notes.txt')! + assert data == 'optimized'.bytes() +} + +fn test_read_dir_uses_open_fallback_and_sorts() { + entries := read_dir(FallbackFS{}, 'dir')! + assert entry_names(entries) == ['a.txt', 'b.txt'] +} + +fn test_read_dir_uses_read_dir_fs_when_available() { + entries := read_dir(OptimizedFS{}, 'dir')! + assert entry_names(entries) == ['m.txt', 'z.txt'] +} + +fn test_stat_uses_open_fallback() { + info := stat(FallbackFS{}, 'notes.txt')! + assert info.name() == 'notes.txt' + assert info.size() == u64('fallback'.len) +} + +fn test_stat_uses_stat_fs_when_available() { + info := stat(OptimizedFS{}, 'notes.txt')! + assert info.name() == 'notes.txt' + assert info.size() == 42 + assert info.mod_time() == 202 +} -- 2.39.5