diff --git a/projects/core/src/main/java/dan200/computercraft/core/apis/FSAPI.java b/projects/core/src/main/java/dan200/computercraft/core/apis/FSAPI.java index fd75c2f92..c300bd0df 100644 --- a/projects/core/src/main/java/dan200/computercraft/core/apis/FSAPI.java +++ b/projects/core/src/main/java/dan200/computercraft/core/apis/FSAPI.java @@ -443,28 +443,6 @@ public class FSAPI implements ILuaAPI { } } - /** - * Searches for files matching a string with wildcards. - *

- * This string is formatted like a normal path string, but can include any - * number of wildcards ({@code *}) to look for files matching anything. - * For example, rom/*/command* will look for any path starting with - * {@code command} inside any subdirectory of {@code /rom}. - * - * @param path The wildcard-qualified path to search for. - * @return A list of paths that match the search string. - * @throws LuaException If the path doesn't exist. - * @cc.since 1.6 - */ - @LuaFunction - public final String[] find(String path) throws LuaException { - try (var ignored = environment.time(Metrics.FS_OPS)) { - return getFileSystem().find(path); - } catch (FileSystemException e) { - throw new LuaException(e.getMessage()); - } - } - /** * Returns the capacity of the drive the path is located on. * diff --git a/projects/core/src/main/java/dan200/computercraft/core/filesystem/FileSystem.java b/projects/core/src/main/java/dan200/computercraft/core/filesystem/FileSystem.java index d43918aaa..139e69e2f 100644 --- a/projects/core/src/main/java/dan200/computercraft/core/filesystem/FileSystem.java +++ b/projects/core/src/main/java/dan200/computercraft/core/filesystem/FileSystem.java @@ -166,47 +166,6 @@ public class FileSystem { return array; } - private void findIn(String dir, List matches, Pattern wildPattern) throws FileSystemException { - var list = list(dir); - for (var entry : list) { - var entryPath = dir.isEmpty() ? entry : dir + "/" + entry; - if (wildPattern.matcher(entryPath).matches()) { - matches.add(entryPath); - } - if (isDir(entryPath)) { - findIn(entryPath, matches, wildPattern); - } - } - } - - public synchronized String[] find(String wildPath) throws FileSystemException { - // Match all the files on the system - wildPath = sanitizePath(wildPath, true); - - // If we don't have a wildcard at all just check the file exists - var starIndex = wildPath.indexOf('*'); - if (starIndex == -1) { - return exists(wildPath) ? new String[]{ wildPath } : new String[0]; - } - - // Find the all non-wildcarded directories. For instance foo/bar/baz* -> foo/bar - var prevDir = wildPath.substring(0, starIndex).lastIndexOf('/'); - var startDir = prevDir == -1 ? "" : wildPath.substring(0, prevDir); - - // If this isn't a directory then just abort - if (!isDir(startDir)) return new String[0]; - - // Scan as normal, starting from this directory - var wildPattern = Pattern.compile("^\\Q" + wildPath.replaceAll("\\*", "\\\\E[^\\\\/]*\\\\Q") + "\\E$"); - List matches = new ArrayList<>(); - findIn(startDir, matches, wildPattern); - - // Return matches - var array = new String[matches.size()]; - matches.toArray(array); - return array; - } - public synchronized boolean exists(String path) throws FileSystemException { path = sanitizePath(path); var mount = getMount(path); @@ -400,21 +359,20 @@ public class FileSystem { private static final Pattern threeDotsPattern = Pattern.compile("^\\.{3,}$"); + // IMPORTANT: Both arrays are sorted by ASCII value. + private static final char[] specialChars = new char[]{ '"', '*', ':', '<', '>', '?', '|' }; + private static final char[] specialCharsAllowWildcards = new char[]{ '"', ':', '<', '>', '|' }; + public static String sanitizePath(String path, boolean allowWildcards) { // Allow windowsy slashes path = path.replace('\\', '/'); // Clean the path or illegal characters. - final var specialChars = new char[]{ - '"', ':', '<', '>', '?', '|', // Sorted by ascii value (important) - }; - var cleanName = new StringBuilder(); + var allowedChars = allowWildcards ? specialCharsAllowWildcards : specialChars; for (var i = 0; i < path.length(); i++) { var c = path.charAt(i); - if (c >= 32 && Arrays.binarySearch(specialChars, c) < 0 && (allowWildcards || c != '*')) { - cleanName.append(c); - } + if (c >= 32 && Arrays.binarySearch(allowedChars, c) < 0) cleanName.append(c); } path = cleanName.toString(); diff --git a/projects/core/src/main/resources/data/computercraft/lua/rom/apis/fs.lua b/projects/core/src/main/resources/data/computercraft/lua/rom/apis/fs.lua index 1a0fb4b53..0643fd120 100644 --- a/projects/core/src/main/resources/data/computercraft/lua/rom/apis/fs.lua +++ b/projects/core/src/main/resources/data/computercraft/lua/rom/apis/fs.lua @@ -133,6 +133,93 @@ function fs.complete(sPath, sLocation, bIncludeFiles, bIncludeDirs) return {} end +local function find_aux(path, parts, i, out) + local part = parts[i] + if not part then + -- If we're at the end of the pattern, ensure our path exists and append it. + if fs.exists(path) then out[#out + 1] = path end + elseif part.exact then + -- If we're an exact match, just recurse into this directory. + return find_aux(fs.combine(path, part.contents), parts, i + 1, out) + else + -- Otherwise we're a pattern. Check we're a directory, then recurse into each + -- matching file. + if not fs.isDir(path) then return end + + local files = fs.list(path) + for j = 1, #files do + local file = files[j] + if file:find(part.contents) then find_aux(fs.combine(path, file), parts, i + 1, out) end + end + end +end + +local find_escape = { + -- Escape standard Lua pattern characters + ["^"] = "%^", ["$"] = "%$", ["("] = "%(", [")"] = "%)", ["%"] = "%%", + ["."] = "%.", ["["] = "%[", ["]"] = "%]", ["+"] = "%+", ["-"] = "%-", + -- Aside from our wildcards. + ["*"] = ".*", + ["?"] = ".", +} + +--[[- Searches for files matching a string with wildcards. + +This string looks like a normal path string, but can include wildcards, which +can match multiple paths: + + - "?" matches any single character in a file name. + - "*" matches any number of characters. + +For example, `rom/*/command*` will look for any path starting with `command` +inside any subdirectory of `/rom`. + +Note that these wildcards match a single segment of the path. For instance +`rom/*.lua` will include `rom/startup.lua` but _not_ include `rom/programs/list.lua`. + +@tparam string path The wildcard-qualified path to search for. +@treturn { string... } A list of paths that match the search string. +@throws If the supplied path was invalid. +@since 1.6 +@changed 1.106.0 Added support for the `?` wildcard. + +@usage List all Markdown files in the help folder + + fs.find("rom/help/*.md") +]] +function fs.find(pattern) + expect(1, pattern, "string") + + pattern = fs.combine(pattern) -- Normalise the path, removing ".."s. + + -- If the pattern is trying to search outside the computer root, just abort. + -- This will fail later on anyway. + if pattern == ".." or pattern:sub(1, 3) == "../" then + error("/" .. pattern .. ": Invalid Path", 2) + end + + -- If we've no wildcards, just check the file exists. + if not pattern:find("[*?]") then + if fs.exists(pattern) then return { pattern } else return {} end + end + + local parts = {} + for part in pattern:gmatch("[^/]+") do + if part:find("[*?]") then + parts[#parts + 1] = { + exact = false, + contents = "^" .. part:gsub(".", find_escape) .. "$", + } + else + parts[#parts + 1] = { exact = true, contents = part } + end + end + + local out = {} + find_aux("", parts, 1, out) + return out +end + --- Returns true if a path is mounted to the parent filesystem. -- -- The root filesystem "/" is considered a mount, along with disk folders and diff --git a/projects/core/src/test/resources/test-rom/spec/apis/fs_spec.lua b/projects/core/src/test/resources/test-rom/spec/apis/fs_spec.lua index ca540985f..156dafdd5 100644 --- a/projects/core/src/test/resources/test-rom/spec/apis/fs_spec.lua +++ b/projects/core/src/test/resources/test-rom/spec/apis/fs_spec.lua @@ -87,6 +87,53 @@ describe("The fs library", function() end) end) + describe("fs.find", function() + it("fails on invalid paths", function() + expect.error(fs.find, ".."):eq("/..: Invalid Path") + expect.error(fs.find, "../foo/bar"):eq("/../foo/bar: Invalid Path") + end) + + it("returns nothing on non-existent files", function() + expect(fs.find("no/such/file")):same {} + expect(fs.find("no/such/*")):same {} + expect(fs.find("no/*/file")):same {} + end) + + it("returns a single file", function() + expect(fs.find("rom")):same { "rom" } + expect(fs.find("rom/motd.txt")):same { "rom/motd.txt" } + end) + + it("supports the '*' wildcard", function() + expect(fs.find("rom/*")):same { + "rom/apis", + "rom/autorun", + "rom/help", + "rom/modules", + "rom/motd.txt", + "rom/programs", + "rom/startup.lua", + } + expect(fs.find("rom/*/command")):same { + "rom/apis/command", + "rom/modules/command", + "rom/programs/command", + } + + expect(fs.find("rom/*/lua*")):same { + "rom/help/lua.txt", + "rom/programs/lua.lua", + } + end) + + it("supports the '?' wildcard", function() + expect(fs.find("rom/programs/mo??.lua")):same { + "rom/programs/motd.lua", + "rom/programs/move.lua", + } + end) + end) + describe("fs.combine", function() it("removes . and ..", function() expect(fs.combine("./a/b")):eq("a/b")