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")