diff --git a/docs/.vitepress/en.ts b/docs/.vitepress/en.ts index 895efbd5..b82b6665 100644 --- a/docs/.vitepress/en.ts +++ b/docs/.vitepress/en.ts @@ -82,6 +82,7 @@ function sidebar(): DefaultTheme.Sidebar { {text: 'json', link: '/plugins/library/json'}, {text: 'strings', link: '/plugins/library/strings'}, {text: 'archiver', link: '/plugins/library/archiver'}, + {text: 'fs', link: '/plugins/library/fs'}, ] }, @@ -94,4 +95,4 @@ function sidebar(): DefaultTheme.Sidebar { ] }, ] -} \ No newline at end of file +} diff --git a/docs/.vitepress/zh.ts b/docs/.vitepress/zh.ts index 4bafcd88..a3065246 100644 --- a/docs/.vitepress/zh.ts +++ b/docs/.vitepress/zh.ts @@ -90,6 +90,7 @@ function sidebar(): DefaultTheme.Sidebar { {text: 'json', link: '/zh-hans/plugins/library/json'}, {text: 'strings', link: '/zh-hans/plugins/library/strings'}, {text: 'archiver', link: '/zh-hans/plugins/library/archiver'}, + {text: 'fs', link: '/zh-hans/plugins/library/fs'}, ] }, @@ -102,4 +103,4 @@ function sidebar(): DefaultTheme.Sidebar { ] }, ] -} \ No newline at end of file +} diff --git a/docs/plugins/library/fs.md b/docs/plugins/library/fs.md new file mode 100644 index 00000000..ada2995e --- /dev/null +++ b/docs/plugins/library/fs.md @@ -0,0 +1,22 @@ +# FS Library + +`vfox` exposes basic path utilities to Lua plugins. Use `require("fs")` to access them. Every API works with both files and directories without exposing file-content reading or writing. + +## Copy, remove, and move paths + +```lua +local fs = require("fs") + +fs.copy(source_path, destination_path) +fs.move(source_path, destination_path) +fs.remove(path) +``` + +## Create a symbolic link + +```lua +local fs = require("fs") +fs.symlink(source_path, link_path) +``` + +Paths may be absolute or relative to the current `vfox` process. Every operation returns `true` on success and raises a Lua error on failure. Directory copies and removals are recursive. Like `mv`, `move` renames when its destination is a new path and moves the source under its original basename when the destination is an existing directory. diff --git a/docs/zh-hans/plugins/library/fs.md b/docs/zh-hans/plugins/library/fs.md new file mode 100644 index 00000000..c9ac8d57 --- /dev/null +++ b/docs/zh-hans/plugins/library/fs.md @@ -0,0 +1,22 @@ +# FS 标准库 + +`vfox` 为 Lua 插件提供基本的路径工具。使用 `require("fs")` 访问这些接口。所有接口都同时支持文件和目录,但不提供文件内容读写能力。 + +## 复制、删除和移动路径 + +```lua +local fs = require("fs") + +fs.copy(source_path, destination_path) +fs.move(source_path, destination_path) +fs.remove(path) +``` + +## 创建符号链接 + +```lua +local fs = require("fs") +fs.symlink(source_path, link_path) +``` + +路径可以是绝对路径,也可以是相对于当前 `vfox` 进程的路径。所有操作成功时均返回 `true`,失败时抛出 Lua 错误。复制和删除目录时会递归处理其内容。与 `mv` 一样,`move` 的目标是新路径时会执行重命名;目标是已有目录时,会将源路径按原 basename 移入该目录。 diff --git a/internal/plugin/luai/module/file/file.go b/internal/plugin/luai/module/file/file.go deleted file mode 100644 index 6f8ec125..00000000 --- a/internal/plugin/luai/module/file/file.go +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright 2026 Han Li and contributors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package html - -import ( - "os" - "path/filepath" - - lua "github.com/yuin/gopher-lua" -) - -const luaFileTypeName = "file_operation" - -type FileOperation struct { - rootPath string -} - -func (f *FileOperation) symlink(L *lua.LState) int { - src := L.CheckString(1) - dest := L.CheckString(2) - // TODO check - err := os.Symlink(filepath.Join(f.rootPath, src), filepath.Join(f.rootPath, dest)) - if err != nil { - L.RaiseError("%s", err.Error()) - return 0 - } - L.Push(lua.LTrue) - return 1 -} - -func (f *FileOperation) luaMap() map[string]lua.LGFunction { - return map[string]lua.LGFunction{ - "symlink": f.symlink, - } -} - -func (f *FileOperation) loader(L *lua.LState) int { - t := L.NewTable() - L.SetFuncs(t, f.luaMap()) - L.Push(t) - return 1 -} - -func Preload(L *lua.LState, rootPath string) { - operation := &FileOperation{rootPath: rootPath} - L.PreloadModule("file", operation.loader) -} diff --git a/internal/plugin/luai/module/fs/fs.go b/internal/plugin/luai/module/fs/fs.go new file mode 100644 index 00000000..bbe8b812 --- /dev/null +++ b/internal/plugin/luai/module/fs/fs.go @@ -0,0 +1,110 @@ +/* + * Copyright 2026 Han Li and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fs + +import ( + "os" + "path/filepath" + + lua "github.com/yuin/gopher-lua" +) + +type Operation struct { + rootPath string +} + +func (f *Operation) path(path string) string { + return filepath.Join(f.rootPath, path) +} + +func raiseOnError(L *lua.LState, err error) { + if err != nil { + L.RaiseError("%s", err.Error()) + } +} + +func returnTrue(L *lua.LState) int { + L.Push(lua.LTrue) + return 1 +} + +func (f *Operation) copy(L *lua.LState) int { + src := L.CheckString(1) + dest := L.CheckString(2) + info, err := os.Stat(f.path(src)) + raiseOnError(L, err) + if info.IsDir() { + raiseOnError(L, os.CopyFS(f.path(dest), os.DirFS(f.path(src)))) + return returnTrue(L) + } + content, err := os.ReadFile(f.path(src)) + raiseOnError(L, err) + raiseOnError(L, os.WriteFile(f.path(dest), content, info.Mode().Perm())) + return returnTrue(L) +} + +func (f *Operation) remove(L *lua.LState) int { + path := L.CheckString(1) + info, err := os.Lstat(f.path(path)) + raiseOnError(L, err) + if info.IsDir() { + raiseOnError(L, os.RemoveAll(f.path(path))) + } else { + raiseOnError(L, os.Remove(f.path(path))) + } + return returnTrue(L) +} + +func (f *Operation) move(L *lua.LState) int { + src := L.CheckString(1) + dest := L.CheckString(2) + srcPath := f.path(src) + destPath := f.path(dest) + if info, err := os.Stat(destPath); err == nil && info.IsDir() { + destPath = filepath.Join(destPath, filepath.Base(srcPath)) + } + raiseOnError(L, os.Rename(srcPath, destPath)) + return returnTrue(L) +} + +func (f *Operation) symlink(L *lua.LState) int { + src := L.CheckString(1) + dest := L.CheckString(2) + raiseOnError(L, os.Symlink(f.path(src), f.path(dest))) + return returnTrue(L) +} + +func (f *Operation) luaMap() map[string]lua.LGFunction { + return map[string]lua.LGFunction{ + "copy": f.copy, + "remove": f.remove, + "move": f.move, + "symlink": f.symlink, + } +} + +func (f *Operation) loader(L *lua.LState) int { + t := L.NewTable() + L.SetFuncs(t, f.luaMap()) + L.Push(t) + return 1 +} + +func Preload(L *lua.LState, rootPath string) { + operation := &Operation{rootPath: rootPath} + L.PreloadModule("fs", operation.loader) +} diff --git a/internal/plugin/luai/module/fs/fs_test.go b/internal/plugin/luai/module/fs/fs_test.go new file mode 100644 index 00000000..108671dd --- /dev/null +++ b/internal/plugin/luai/module/fs/fs_test.go @@ -0,0 +1,255 @@ +/* + * Copyright 2026 Han Li and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fs + +import ( + "os" + "path/filepath" + "strings" + "testing" + + lua "github.com/yuin/gopher-lua" +) + +func runLua(t *testing.T, root, script string) { + t.Helper() + L := lua.NewState() + defer L.Close() + Preload(L, root) + if err := L.DoString(script); err != nil { + t.Fatal(err) + } +} + +func writeFile(t *testing.T, path, content string, mode os.FileMode) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), mode); err != nil { + t.Fatal(err) + } +} + +func assertFileContent(t *testing.T, path, want string) { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(content) != want { + t.Fatalf("content of %s = %q, want %q", path, content, want) + } +} + +func assertNotExist(t *testing.T, path string) { + t.Helper() + if _, err := os.Lstat(path); !os.IsNotExist(err) { + t.Fatalf("%s still exists or stat failed unexpectedly: %v", path, err) + } +} + +func TestRequire(t *testing.T) { + runLua(t, t.TempDir(), ` + local fs = require("fs") + assert(type(fs) == "table") + assert(type(fs.copy) == "function") + assert(type(fs.remove) == "function") + assert(type(fs.move) == "function") + assert(type(fs.symlink) == "function") + assert(fs.read == nil) + assert(fs.write == nil) + assert(pcall(require, "file") == false) + `) +} + +func TestCopyFilePreservesContentAndMode(t *testing.T) { + root := t.TempDir() + source := filepath.Join(root, "source.txt") + destination := filepath.Join(root, "destination.txt") + writeFile(t, source, "vfox", 0o700) + + runLua(t, root, `assert(require("fs").copy("source.txt", "destination.txt") == true)`) + + assertFileContent(t, destination, "vfox") + sourceInfo, err := os.Stat(source) + if err != nil { + t.Fatal(err) + } + destinationInfo, err := os.Stat(destination) + if err != nil { + t.Fatal(err) + } + if destinationInfo.Mode().Perm() != sourceInfo.Mode().Perm() { + t.Fatalf("copied file mode = %v, want %v", destinationInfo.Mode().Perm(), sourceInfo.Mode().Perm()) + } +} + +func TestCopyDirectoryRecursively(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "source", "nested", "content.txt"), "nested", 0o600) + if err := os.MkdirAll(filepath.Join(root, "source", "empty"), 0o755); err != nil { + t.Fatal(err) + } + + runLua(t, root, `assert(require("fs").copy("source", "destination") == true)`) + + assertFileContent(t, filepath.Join(root, "destination", "nested", "content.txt"), "nested") + if info, err := os.Stat(filepath.Join(root, "destination", "empty")); err != nil || !info.IsDir() { + t.Fatalf("empty directory was not copied: %v", err) + } +} + +func TestRemoveFile(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "remove.txt") + writeFile(t, path, "remove", 0o600) + + runLua(t, root, `assert(require("fs").remove("remove.txt") == true)`) + assertNotExist(t, path) +} + +func TestRemoveDirectoryRecursively(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "remove-dir") + writeFile(t, filepath.Join(path, "nested", "content.txt"), "remove", 0o600) + + runLua(t, root, `assert(require("fs").remove("remove-dir") == true)`) + assertNotExist(t, path) +} + +func TestRemoveDirectorySymlinkKeepsTarget(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "target") + link := filepath.Join(root, "target-link") + writeFile(t, filepath.Join(target, "content.txt"), "keep", 0o600) + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + + runLua(t, root, `assert(require("fs").remove("target-link") == true)`) + + assertNotExist(t, link) + assertFileContent(t, filepath.Join(target, "content.txt"), "keep") +} + +func TestMoveRenamesFile(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "before.txt"), "rename", 0o600) + + runLua(t, root, `assert(require("fs").move("before.txt", "after.txt") == true)`) + + assertNotExist(t, filepath.Join(root, "before.txt")) + assertFileContent(t, filepath.Join(root, "after.txt"), "rename") +} + +func TestMoveRenamesDirectory(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "before", "nested", "content.txt"), "rename", 0o600) + + runLua(t, root, `assert(require("fs").move("before", "after") == true)`) + + assertNotExist(t, filepath.Join(root, "before")) + assertFileContent(t, filepath.Join(root, "after", "nested", "content.txt"), "rename") +} + +func TestMoveIntoExistingDirectory(t *testing.T) { + tests := []struct { + name string + sourcePath string + moveSource string + content string + destination string + }{ + {name: "file", sourcePath: "item.txt", moveSource: "item.txt", content: "file", destination: filepath.Join("destination", "item.txt")}, + {name: "directory", sourcePath: filepath.Join("item", "nested.txt"), moveSource: "item", content: "directory", destination: filepath.Join("destination", "item", "nested.txt")}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, test.sourcePath), test.content, 0o600) + if err := os.Mkdir(filepath.Join(root, "destination"), 0o755); err != nil { + t.Fatal(err) + } + + runLua(t, root, `assert(require("fs").move("`+test.moveSource+`", "destination") == true)`) + + assertFileContent(t, filepath.Join(root, test.destination), test.content) + }) + } +} + +func TestSymlinkFileAndDirectory(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "target.txt"), "file", 0o600) + writeFile(t, filepath.Join(root, "target-dir", "content.txt"), "directory", 0o600) + + runLua(t, root, ` + local fs = require("fs") + assert(fs.symlink("target.txt", "file-link") == true) + assert(fs.symlink("target-dir", "dir-link") == true) + `) + + assertFileContent(t, filepath.Join(root, "file-link"), "file") + assertFileContent(t, filepath.Join(root, "dir-link", "content.txt"), "directory") +} + +func TestOperationsReportErrors(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "already-exists"), nil, 0o600); err != nil { + t.Fatal(err) + } + + runLua(t, root, ` + local fs = require("fs") + local cases = { + {fs.copy, "missing", "copy"}, + {fs.copy, "already-exists", "missing/copy"}, + {fs.remove, "missing"}, + {fs.move, "missing", "move"}, + {fs.symlink, "target", "already-exists"}, + } + for _, case in ipairs(cases) do + local ok, err = pcall(case[1], case[2], case[3]) + assert(ok == false) + assert(type(err) == "string") + end + `) +} + +func TestOperationsValidateArguments(t *testing.T) { + L := lua.NewState() + defer L.Close() + Preload(L, t.TempDir()) + + for _, operation := range []string{"copy", "remove", "move", "symlink"} { + err := L.DoString(`require("fs").` + operation + `(nil, "link")`) + if err == nil { + t.Fatalf("%s() with a non-string path returned no error", operation) + } + if !strings.Contains(err.Error(), "string expected") { + t.Fatalf("%s() error = %q, want an argument type error", operation, err) + } + } + for _, operation := range []string{"copy", "move", "symlink"} { + err := L.DoString(`require("fs").` + operation + `("source", nil)`) + if err == nil || !strings.Contains(err.Error(), "string expected") { + t.Fatalf("%s() accepted a non-string destination: %v", operation, err) + } + } +} diff --git a/internal/plugin/luai/module/module.go b/internal/plugin/luai/module/module.go index 5d5420cb..d605d165 100644 --- a/internal/plugin/luai/module/module.go +++ b/internal/plugin/luai/module/module.go @@ -19,6 +19,7 @@ package module import ( "github.com/version-fox/vfox/internal/config" "github.com/version-fox/vfox/internal/plugin/luai/module/archiver" + "github.com/version-fox/vfox/internal/plugin/luai/module/fs" "github.com/version-fox/vfox/internal/plugin/luai/module/html" "github.com/version-fox/vfox/internal/plugin/luai/module/http" "github.com/version-fox/vfox/internal/plugin/luai/module/json" @@ -36,4 +37,5 @@ func Preload(L *lua.LState, options *PreloadOptions) { html.Preload(L) string.Preload(L) archiver.Preload(L) + fs.Preload(L, "") } diff --git a/internal/plugin/luai/module/file/file_test.go b/internal/plugin/luai/module/module_test.go similarity index 60% rename from internal/plugin/luai/module/file/file_test.go rename to internal/plugin/luai/module/module_test.go index 7ba0c52e..55e91032 100644 --- a/internal/plugin/luai/module/file/file_test.go +++ b/internal/plugin/luai/module/module_test.go @@ -14,29 +14,27 @@ * limitations under the License. */ -package html +package module import ( "testing" + "github.com/version-fox/vfox/internal/config" lua "github.com/yuin/gopher-lua" ) -func TestRequire(t *testing.T) { - const str = ` - local file = require("file") - assert(type(file) == "table") - assert(type(file.symlink) == "function") - ` - evalLua(str, t) -} +func TestPreloadIncludesFileModule(t *testing.T) { + L := lua.NewState() + defer L.Close() -func evalLua(str string, t *testing.T) { - s := lua.NewState() - defer s.Close() - Preload(s, "") - if err := s.DoString(str); err != nil { - t.Error(err) + Preload(L, &PreloadOptions{Config: config.DefaultConfig}) + if err := L.DoString(` + local fs = require("fs") + assert(type(fs.copy) == "function") + assert(type(fs.remove) == "function") + assert(type(fs.move) == "function") + assert(type(fs.symlink) == "function") + `); err != nil { + t.Fatal(err) } - }