From d19529cb5d616694930a86a9baa7dc6fc2bd43a0 Mon Sep 17 00:00:00 2001 From: Fqin <3374927190@qq.com> Date: Sat, 20 Jun 2026 13:24:14 +0800 Subject: [PATCH 1/3] feat(plugin): add Lua file and directory operations --- docs/.vitepress/en.ts | 3 +- docs/.vitepress/zh.ts | 3 +- docs/plugins/library/file.md | 22 +++ docs/zh-hans/plugins/library/file.md | 22 +++ internal/plugin/luai/module/file/file.go | 62 ++++++-- internal/plugin/luai/module/file/file_test.go | 141 ++++++++++++++++-- internal/plugin/luai/module/module.go | 2 + internal/plugin/luai/module/module_test.go | 40 +++++ 8 files changed, 271 insertions(+), 24 deletions(-) create mode 100644 docs/plugins/library/file.md create mode 100644 docs/zh-hans/plugins/library/file.md create mode 100644 internal/plugin/luai/module/module_test.go diff --git a/docs/.vitepress/en.ts b/docs/.vitepress/en.ts index 895efbd5..1807b673 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: 'file', link: '/plugins/library/file'}, ] }, @@ -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..0b396282 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: 'file', link: '/zh-hans/plugins/library/file'}, ] }, @@ -102,4 +103,4 @@ function sidebar(): DefaultTheme.Sidebar { ] }, ] -} \ No newline at end of file +} diff --git a/docs/plugins/library/file.md b/docs/plugins/library/file.md new file mode 100644 index 00000000..4d8a957f --- /dev/null +++ b/docs/plugins/library/file.md @@ -0,0 +1,22 @@ +# File Library + +`vfox` exposes basic path utilities to Lua plugins. Use `require("file")` 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 file = require("file") + +file.copy(source_path, destination_path) +file.move(source_path, destination_path) +file.remove(path) +``` + +## Create a symbolic link + +```lua +local file = require("file") +file.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. diff --git a/docs/zh-hans/plugins/library/file.md b/docs/zh-hans/plugins/library/file.md new file mode 100644 index 00000000..489c87db --- /dev/null +++ b/docs/zh-hans/plugins/library/file.md @@ -0,0 +1,22 @@ +# File 标准库 + +`vfox` 为 Lua 插件提供基本的路径工具。使用 `require("file")` 访问这些接口。所有接口都同时支持文件和目录,但不提供文件内容读写能力。 + +## 复制、删除和移动路径 + +```lua +local file = require("file") + +file.copy(source_path, destination_path) +file.move(source_path, destination_path) +file.remove(path) +``` + +## 创建符号链接 + +```lua +local file = require("file") +file.symlink(source_path, link_path) +``` + +路径可以是绝对路径,也可以是相对于当前 `vfox` 进程的路径。所有操作成功时均返回 `true`,失败时抛出 Lua 错误。复制和删除目录时会递归处理其内容。 diff --git a/internal/plugin/luai/module/file/file.go b/internal/plugin/luai/module/file/file.go index 6f8ec125..e1149cf0 100644 --- a/internal/plugin/luai/module/file/file.go +++ b/internal/plugin/luai/module/file/file.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package html +package file import ( "os" @@ -23,27 +23,71 @@ import ( 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)) +func (f *FileOperation) 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()) - return 0 } +} + +func returnTrue(L *lua.LState) int { L.Push(lua.LTrue) return 1 } +func (f *FileOperation) 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 *FileOperation) 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 *FileOperation) move(L *lua.LState) int { + src := L.CheckString(1) + dest := L.CheckString(2) + raiseOnError(L, os.Rename(f.path(src), f.path(dest))) + return returnTrue(L) +} + +func (f *FileOperation) 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 *FileOperation) luaMap() map[string]lua.LGFunction { return map[string]lua.LGFunction{ + "copy": f.copy, + "remove": f.remove, + "move": f.move, "symlink": f.symlink, } } diff --git a/internal/plugin/luai/module/file/file_test.go b/internal/plugin/luai/module/file/file_test.go index 7ba0c52e..f4f4bf07 100644 --- a/internal/plugin/luai/module/file/file_test.go +++ b/internal/plugin/luai/module/file/file_test.go @@ -14,29 +14,144 @@ * limitations under the License. */ -package html +package file import ( + "os" + "path/filepath" + "strings" "testing" 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") +func TestPreload(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "target.txt") + if err := os.WriteFile(target, []byte("vfox"), 0o600); err != nil { + t.Fatal(err) + } + for _, dir := range []string{"source-dir/nested", "delete-dir/nested"} { + if err := os.MkdirAll(filepath.Join(root, filepath.FromSlash(dir)), 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(root, "source-dir", "nested", "content.txt"), []byte("directory"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "delete-dir", "nested", "content.txt"), []byte("delete"), 0o600); err != nil { + t.Fatal(err) + } + + L := lua.NewState() + defer L.Close() + Preload(L, root) + + script := ` + local file = require("file") + assert(type(file) == "table") + assert(type(file.copy) == "function") + assert(type(file.remove) == "function") + assert(type(file.move) == "function") + assert(type(file.symlink) == "function") + assert(file.read == nil) + assert(file.write == nil) + + assert(file.copy("target.txt", "copied.txt") == true) + assert(file.copy("target.txt", "preserved-mode.txt") == true) + assert(file.move("copied.txt", "moved.txt") == true) + assert(file.symlink("target.txt", "target-link.txt") == true) + assert(file.remove("moved.txt") == true) + + assert(file.copy("source-dir", "copied-dir") == true) + assert(file.move("copied-dir", "moved-dir") == true) + assert(file.symlink("source-dir", "source-dir-link") == true) + assert(file.remove("delete-dir") == true) ` - evalLua(str, t) + if err := L.DoString(script); err != nil { + t.Fatal(err) + } + + content, err := os.ReadFile(filepath.Join(root, "target-link.txt")) + if err != nil { + t.Fatal(err) + } + if string(content) != "vfox" { + t.Fatalf("linked file content = %q, want %q", content, "vfox") + } + sourceInfo, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + copyInfo, err := os.Stat(filepath.Join(root, "preserved-mode.txt")) + if err != nil { + t.Fatal(err) + } + if copyInfo.Mode().Perm() != sourceInfo.Mode().Perm() { + t.Fatalf("copied file mode = %v, want %v", copyInfo.Mode().Perm(), sourceInfo.Mode().Perm()) + } + if _, err := os.Stat(filepath.Join(root, "moved.txt")); !os.IsNotExist(err) { + t.Fatalf("removed file still exists or stat failed unexpectedly: %v", err) + } + for _, path := range []string{ + filepath.Join(root, "moved-dir", "nested", "content.txt"), + filepath.Join(root, "source-dir-link", "nested", "content.txt"), + } { + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(content) != "directory" { + t.Fatalf("directory file content = %q, want %q", content, "directory") + } + } + if _, err := os.Stat(filepath.Join(root, "delete-dir")); !os.IsNotExist(err) { + t.Fatalf("recursively removed directory still exists or stat failed unexpectedly: %v", err) + } } -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) +func TestOperationsReportErrors(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "already-exists"), nil, 0o600); err != nil { + t.Fatal(err) + } + + L := lua.NewState() + defer L.Close() + Preload(L, root) + + script := ` + local file = require("file") + local cases = { + {file.copy, "missing", "copy"}, + {file.copy, "already-exists", "missing/copy"}, + {file.remove, "missing"}, + {file.move, "missing", "move"}, + {file.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 + ` + if err := L.DoString(script); err != nil { + t.Fatal(err) } +} + +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("file").` + 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) + } + } } diff --git a/internal/plugin/luai/module/module.go b/internal/plugin/luai/module/module.go index 5d5420cb..4a512ec7 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/file" "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) + file.Preload(L, "") } diff --git a/internal/plugin/luai/module/module_test.go b/internal/plugin/luai/module/module_test.go new file mode 100644 index 00000000..b8601a20 --- /dev/null +++ b/internal/plugin/luai/module/module_test.go @@ -0,0 +1,40 @@ +/* + * 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 module + +import ( + "testing" + + "github.com/version-fox/vfox/internal/config" + lua "github.com/yuin/gopher-lua" +) + +func TestPreloadIncludesFileModule(t *testing.T) { + L := lua.NewState() + defer L.Close() + + Preload(L, &PreloadOptions{Config: config.DefaultConfig}) + if err := L.DoString(` + local file = require("file") + assert(type(file.copy) == "function") + assert(type(file.remove) == "function") + assert(type(file.move) == "function") + assert(type(file.symlink) == "function") + `); err != nil { + t.Fatal(err) + } +} From 55437c4378dbb58c093b17d884d27d801479be24 Mon Sep 17 00:00:00 2001 From: Fqin <3374927190@qq.com> Date: Sat, 20 Jun 2026 13:32:40 +0800 Subject: [PATCH 2/3] start ci acitons From 34eecb2e0ced24f310053d6ff031bd4cc8138dd6 Mon Sep 17 00:00:00 2001 From: Fqin <3374927190@qq.com> Date: Mon, 22 Jun 2026 14:41:15 +0800 Subject: [PATCH 3/3] refactor(plugin): rename file module to fs --- docs/.vitepress/en.ts | 2 +- docs/.vitepress/zh.ts | 2 +- docs/plugins/library/file.md | 22 -- docs/plugins/library/fs.md | 22 ++ docs/zh-hans/plugins/library/file.md | 22 -- docs/zh-hans/plugins/library/fs.md | 22 ++ internal/plugin/luai/module/file/file_test.go | 157 ----------- .../luai/module/{file/file.go => fs/fs.go} | 29 +- internal/plugin/luai/module/fs/fs_test.go | 255 ++++++++++++++++++ internal/plugin/luai/module/module.go | 4 +- internal/plugin/luai/module/module_test.go | 10 +- 11 files changed, 325 insertions(+), 222 deletions(-) delete mode 100644 docs/plugins/library/file.md create mode 100644 docs/plugins/library/fs.md delete mode 100644 docs/zh-hans/plugins/library/file.md create mode 100644 docs/zh-hans/plugins/library/fs.md delete mode 100644 internal/plugin/luai/module/file/file_test.go rename internal/plugin/luai/module/{file/file.go => fs/fs.go} (74%) create mode 100644 internal/plugin/luai/module/fs/fs_test.go diff --git a/docs/.vitepress/en.ts b/docs/.vitepress/en.ts index 1807b673..b82b6665 100644 --- a/docs/.vitepress/en.ts +++ b/docs/.vitepress/en.ts @@ -82,7 +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: 'file', link: '/plugins/library/file'}, + {text: 'fs', link: '/plugins/library/fs'}, ] }, diff --git a/docs/.vitepress/zh.ts b/docs/.vitepress/zh.ts index 0b396282..a3065246 100644 --- a/docs/.vitepress/zh.ts +++ b/docs/.vitepress/zh.ts @@ -90,7 +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: 'file', link: '/zh-hans/plugins/library/file'}, + {text: 'fs', link: '/zh-hans/plugins/library/fs'}, ] }, diff --git a/docs/plugins/library/file.md b/docs/plugins/library/file.md deleted file mode 100644 index 4d8a957f..00000000 --- a/docs/plugins/library/file.md +++ /dev/null @@ -1,22 +0,0 @@ -# File Library - -`vfox` exposes basic path utilities to Lua plugins. Use `require("file")` 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 file = require("file") - -file.copy(source_path, destination_path) -file.move(source_path, destination_path) -file.remove(path) -``` - -## Create a symbolic link - -```lua -local file = require("file") -file.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. 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/file.md b/docs/zh-hans/plugins/library/file.md deleted file mode 100644 index 489c87db..00000000 --- a/docs/zh-hans/plugins/library/file.md +++ /dev/null @@ -1,22 +0,0 @@ -# File 标准库 - -`vfox` 为 Lua 插件提供基本的路径工具。使用 `require("file")` 访问这些接口。所有接口都同时支持文件和目录,但不提供文件内容读写能力。 - -## 复制、删除和移动路径 - -```lua -local file = require("file") - -file.copy(source_path, destination_path) -file.move(source_path, destination_path) -file.remove(path) -``` - -## 创建符号链接 - -```lua -local file = require("file") -file.symlink(source_path, link_path) -``` - -路径可以是绝对路径,也可以是相对于当前 `vfox` 进程的路径。所有操作成功时均返回 `true`,失败时抛出 Lua 错误。复制和删除目录时会递归处理其内容。 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_test.go b/internal/plugin/luai/module/file/file_test.go deleted file mode 100644 index f4f4bf07..00000000 --- a/internal/plugin/luai/module/file/file_test.go +++ /dev/null @@ -1,157 +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 file - -import ( - "os" - "path/filepath" - "strings" - "testing" - - lua "github.com/yuin/gopher-lua" -) - -func TestPreload(t *testing.T) { - root := t.TempDir() - target := filepath.Join(root, "target.txt") - if err := os.WriteFile(target, []byte("vfox"), 0o600); err != nil { - t.Fatal(err) - } - for _, dir := range []string{"source-dir/nested", "delete-dir/nested"} { - if err := os.MkdirAll(filepath.Join(root, filepath.FromSlash(dir)), 0o755); err != nil { - t.Fatal(err) - } - } - if err := os.WriteFile(filepath.Join(root, "source-dir", "nested", "content.txt"), []byte("directory"), 0o600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "delete-dir", "nested", "content.txt"), []byte("delete"), 0o600); err != nil { - t.Fatal(err) - } - - L := lua.NewState() - defer L.Close() - Preload(L, root) - - script := ` - local file = require("file") - assert(type(file) == "table") - assert(type(file.copy) == "function") - assert(type(file.remove) == "function") - assert(type(file.move) == "function") - assert(type(file.symlink) == "function") - assert(file.read == nil) - assert(file.write == nil) - - assert(file.copy("target.txt", "copied.txt") == true) - assert(file.copy("target.txt", "preserved-mode.txt") == true) - assert(file.move("copied.txt", "moved.txt") == true) - assert(file.symlink("target.txt", "target-link.txt") == true) - assert(file.remove("moved.txt") == true) - - assert(file.copy("source-dir", "copied-dir") == true) - assert(file.move("copied-dir", "moved-dir") == true) - assert(file.symlink("source-dir", "source-dir-link") == true) - assert(file.remove("delete-dir") == true) - ` - if err := L.DoString(script); err != nil { - t.Fatal(err) - } - - content, err := os.ReadFile(filepath.Join(root, "target-link.txt")) - if err != nil { - t.Fatal(err) - } - if string(content) != "vfox" { - t.Fatalf("linked file content = %q, want %q", content, "vfox") - } - sourceInfo, err := os.Stat(target) - if err != nil { - t.Fatal(err) - } - copyInfo, err := os.Stat(filepath.Join(root, "preserved-mode.txt")) - if err != nil { - t.Fatal(err) - } - if copyInfo.Mode().Perm() != sourceInfo.Mode().Perm() { - t.Fatalf("copied file mode = %v, want %v", copyInfo.Mode().Perm(), sourceInfo.Mode().Perm()) - } - if _, err := os.Stat(filepath.Join(root, "moved.txt")); !os.IsNotExist(err) { - t.Fatalf("removed file still exists or stat failed unexpectedly: %v", err) - } - for _, path := range []string{ - filepath.Join(root, "moved-dir", "nested", "content.txt"), - filepath.Join(root, "source-dir-link", "nested", "content.txt"), - } { - content, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if string(content) != "directory" { - t.Fatalf("directory file content = %q, want %q", content, "directory") - } - } - if _, err := os.Stat(filepath.Join(root, "delete-dir")); !os.IsNotExist(err) { - t.Fatalf("recursively removed directory still exists or stat failed unexpectedly: %v", err) - } -} - -func TestOperationsReportErrors(t *testing.T) { - root := t.TempDir() - if err := os.WriteFile(filepath.Join(root, "already-exists"), nil, 0o600); err != nil { - t.Fatal(err) - } - - L := lua.NewState() - defer L.Close() - Preload(L, root) - - script := ` - local file = require("file") - local cases = { - {file.copy, "missing", "copy"}, - {file.copy, "already-exists", "missing/copy"}, - {file.remove, "missing"}, - {file.move, "missing", "move"}, - {file.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 - ` - if err := L.DoString(script); err != nil { - t.Fatal(err) - } -} - -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("file").` + 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) - } - } -} diff --git a/internal/plugin/luai/module/file/file.go b/internal/plugin/luai/module/fs/fs.go similarity index 74% rename from internal/plugin/luai/module/file/file.go rename to internal/plugin/luai/module/fs/fs.go index e1149cf0..bbe8b812 100644 --- a/internal/plugin/luai/module/file/file.go +++ b/internal/plugin/luai/module/fs/fs.go @@ -14,7 +14,7 @@ * limitations under the License. */ -package file +package fs import ( "os" @@ -23,11 +23,11 @@ import ( lua "github.com/yuin/gopher-lua" ) -type FileOperation struct { +type Operation struct { rootPath string } -func (f *FileOperation) path(path string) string { +func (f *Operation) path(path string) string { return filepath.Join(f.rootPath, path) } @@ -42,7 +42,7 @@ func returnTrue(L *lua.LState) int { return 1 } -func (f *FileOperation) copy(L *lua.LState) int { +func (f *Operation) copy(L *lua.LState) int { src := L.CheckString(1) dest := L.CheckString(2) info, err := os.Stat(f.path(src)) @@ -57,7 +57,7 @@ func (f *FileOperation) copy(L *lua.LState) int { return returnTrue(L) } -func (f *FileOperation) remove(L *lua.LState) int { +func (f *Operation) remove(L *lua.LState) int { path := L.CheckString(1) info, err := os.Lstat(f.path(path)) raiseOnError(L, err) @@ -69,21 +69,26 @@ func (f *FileOperation) remove(L *lua.LState) int { return returnTrue(L) } -func (f *FileOperation) move(L *lua.LState) int { +func (f *Operation) move(L *lua.LState) int { src := L.CheckString(1) dest := L.CheckString(2) - raiseOnError(L, os.Rename(f.path(src), f.path(dest))) + 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 *FileOperation) symlink(L *lua.LState) int { +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 *FileOperation) luaMap() map[string]lua.LGFunction { +func (f *Operation) luaMap() map[string]lua.LGFunction { return map[string]lua.LGFunction{ "copy": f.copy, "remove": f.remove, @@ -92,7 +97,7 @@ func (f *FileOperation) luaMap() map[string]lua.LGFunction { } } -func (f *FileOperation) loader(L *lua.LState) int { +func (f *Operation) loader(L *lua.LState) int { t := L.NewTable() L.SetFuncs(t, f.luaMap()) L.Push(t) @@ -100,6 +105,6 @@ func (f *FileOperation) loader(L *lua.LState) int { } func Preload(L *lua.LState, rootPath string) { - operation := &FileOperation{rootPath: rootPath} - L.PreloadModule("file", operation.loader) + 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 4a512ec7..d605d165 100644 --- a/internal/plugin/luai/module/module.go +++ b/internal/plugin/luai/module/module.go @@ -19,7 +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/file" + "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" @@ -37,5 +37,5 @@ func Preload(L *lua.LState, options *PreloadOptions) { html.Preload(L) string.Preload(L) archiver.Preload(L) - file.Preload(L, "") + fs.Preload(L, "") } diff --git a/internal/plugin/luai/module/module_test.go b/internal/plugin/luai/module/module_test.go index b8601a20..55e91032 100644 --- a/internal/plugin/luai/module/module_test.go +++ b/internal/plugin/luai/module/module_test.go @@ -29,11 +29,11 @@ func TestPreloadIncludesFileModule(t *testing.T) { Preload(L, &PreloadOptions{Config: config.DefaultConfig}) if err := L.DoString(` - local file = require("file") - assert(type(file.copy) == "function") - assert(type(file.remove) == "function") - assert(type(file.move) == "function") - assert(type(file.symlink) == "function") + 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) }