about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorKartik K. Agaram <vc@akkartik.com>2023-08-30 19:04:06 -0700
committerKartik K. Agaram <vc@akkartik.com>2023-08-30 19:04:06 -0700
commitf82c0de5020437bf45afa8d4e441437801466af6 (patch)
tree2aa807519b085bb82dc48269ed683636a5f2b45c
parent7e97a2a1e7564c410236c012d7a6c4b0f141484d (diff)
downloadlines.love-f82c0de5020437bf45afa8d4e441437801466af6.tar.gz
cleaner API for file-system access
Thanks to physfs and nativefs.lua

nativefs still introduces some inconsistencies with love.filesystem with
relative paths:

  * love.fs.read: reads from save dir if it exists, falls back to source dir if not
  * nativefs.read: reads from save dir if it exists, falls back to source dir if not ✓

  * love.fs.write: always writes to save dir
  * nativefs.write: always writes to source dir (since no restrictions)

  * love.fs.newFile followed by file:open('r'): reads from save dir if it exists, source dir if not
  * nativefs.newFile followed by file:open('r'): always reads from working dir

  * love.fs.newFile followed by file:open('w'): always writes to save dir
  * nativefs.newFile followed by file:open('w'): always writes to working dir

So avoid using relative paths with App primitives.
-rw-r--r--app.lua78
-rw-r--r--nativefs.lua496
-rw-r--r--reference.md8
-rw-r--r--source_file.lua5
4 files changed, 535 insertions, 52 deletions
diff --git a/app.lua b/app.lua
index 7099596..477a9b3 100644
--- a/app.lua
+++ b/app.lua
@@ -97,6 +97,9 @@ function App.initialize_for_test()
   App.screen.init{width=100, height=50}
   App.screen.contents = {}  -- clear screen
   App.filesystem = {}
+  App.source_dir = ''
+  App.current_dir = ''
+  App.save_dir = ''
   App.fake_keys_pressed = {}
   App.fake_mouse_state = {x=-1, y=-1}
   App.initialize_globals()
@@ -257,26 +260,13 @@ end
 
 function App.open_for_writing(filename)
   App.filesystem[filename] = ''
-  if Current_app == nil or Current_app == 'run' then
-    return {
-      write = function(self, ...)
-                local args = {...}
-                for i,s in ipairs(args) do
-                  App.filesystem[filename] = App.filesystem[filename]..s
-                end
-              end,
-      close = function(self)
-              end,
-    }
-  elseif Current_app == 'source' then
-    return {
-      write = function(self, s)
-                App.filesystem[filename] = App.filesystem[filename]..s
-              end,
-      close = function(self)
-              end,
-    }
-  end
+  return {
+    write = function(self, s)
+              App.filesystem[filename] = App.filesystem[filename]..s
+            end,
+    close = function(self)
+            end,
+  }
 end
 
 function App.open_for_reading(filename)
@@ -356,6 +346,8 @@ function prepend_debug_info_to_test_failure(test_name, err)
   table.insert(Test_errors, full_error)
 end
 
+nativefs = require 'nativefs'
+
 -- call this once all tests are run
 -- can't run any tests after this
 function App.disable_tests()
@@ -391,32 +383,30 @@ function App.disable_tests()
   App.screen.move = love.window.setPosition
   App.screen.position = love.window.getPosition
   App.screen.print = love.graphics.print
-  if Current_app == nil or Current_app == 'run' then
-    App.open_for_reading = function(filename) return io.open(filename, 'r') end
-    App.open_for_writing = function(filename) return io.open(filename, 'w') end
-  elseif Current_app == 'source' then
-    -- HACK: source editor requires a couple of different foundational definitions
-    App.open_for_reading =
-        function(filename)
-          local result = love.filesystem.newFile(filename)
-          local ok, err = result:open('r')
-          if ok then
-            return result
-          else
-            return ok, err
-          end
+  App.open_for_reading =
+      function(filename)
+        local result = nativefs.newFile(filename)
+        local ok, err = result:open('r')
+        if ok then
+          return result
+        else
+          return ok, err
         end
-    App.open_for_writing =
-        function(filename)
-          local result = love.filesystem.newFile(filename)
-          local ok, err = result:open('w')
-          if ok then
-            return result
-          else
-            return ok, err
-          end
+      end
+  App.open_for_writing =
+      function(filename)
+        local result = nativefs.newFile(filename)
+        local ok, err = result:open('w')
+        if ok then
+          return result
+        else
+          return ok, err
         end
-  end
+      end
+  App.files = nativefs.getDirectoryItems
+  App.source_dir = love.filesystem.getSource()..'/'
+  App.current_dir = nativefs.getWorkingDirectory()..'/'
+  App.save_dir = love.filesystem.getSaveDirectory()..'/'
   App.get_time = love.timer.getTime
   App.get_clipboard = love.system.getClipboardText
   App.set_clipboard = love.system.setClipboardText
diff --git a/nativefs.lua b/nativefs.lua
new file mode 100644
index 0000000..2214bfc
--- /dev/null
+++ b/nativefs.lua
@@ -0,0 +1,496 @@
+--[[

+Copyright 2020 megagrump@pm.me

+

+Permission is hereby granted, free of charge, to any person obtaining a copy of

+this software and associated documentation files (the "Software"), to deal in

+the Software without restriction, including without limitation the rights to

+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies

+of the Software, and to permit persons to whom the Software is furnished to do

+so, subject to the following conditions:

+

+The above copyright notice and this permission notice shall be included in all

+copies or substantial portions of the Software.

+

+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR

+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,

+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE

+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER

+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,

+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE

+SOFTWARE.

+]]--

+

+local ffi, bit = require('ffi'), require('bit')

+local C = ffi.C

+

+local File = {

+	getBuffer = function(self) return self._bufferMode, self._bufferSize end,

+	getFilename = function(self) return self._name end,

+	getMode = function(self) return self._mode end,

+	isOpen = function(self) return self._mode ~= 'c' and self._handle ~= nil end,

+}

+

+local fopen, getcwd, chdir, unlink, mkdir, rmdir

+local BUFFERMODE, MODEMAP

+local ByteArray = ffi.typeof('unsigned char[?]')

+local function _ptr(p) return p ~= nil and p or nil end -- NULL pointer to nil

+

+function File:open(mode)

+	if self._mode ~= 'c' then return false, "File " .. self._name .. " is already open" end

+	if not MODEMAP[mode] then return false, "Invalid open mode for " .. self._name .. ": " .. mode end

+

+	local handle = _ptr(fopen(self._name, MODEMAP[mode]))

+	if not handle then return false, "Could not open " .. self._name .. " in mode " .. mode end

+

+	self._handle, self._mode = ffi.gc(handle, C.fclose), mode

+	self:setBuffer(self._bufferMode, self._bufferSize)

+

+	return true

+end

+

+function File:close()

+	if self._mode == 'c' then return false, "File is not open" end

+	C.fclose(ffi.gc(self._handle, nil))

+	self._handle, self._mode = nil, 'c'

+	return true

+end

+

+function File:setBuffer(mode, size)

+	local bufferMode = BUFFERMODE[mode]

+	if not bufferMode then

+		return false, "Invalid buffer mode " .. mode .. " (expected 'none', 'full', or 'line')"

+	end

+

+	if mode == 'none' then

+		size = math.max(0, size or 0)

+	else

+		size = math.max(2, size or 2) -- Windows requires buffer to be at least 2 bytes

+	end

+

+	local success = self._mode == 'c' or C.setvbuf(self._handle, nil, bufferMode, size) == 0

+	if not success then

+		self._bufferMode, self._bufferSize = 'none', 0

+		return false, "Could not set buffer mode"

+	end

+

+	self._bufferMode, self._bufferSize = mode, size

+	return true

+end

+

+function File:getSize()

+	-- NOTE: The correct way to do this would be a stat() call, which requires a

+	-- lot more (system-specific) code. This is a shortcut that requires the file

+	-- to be readable.

+	local mustOpen = not self:isOpen()

+	if mustOpen and not self:open('r') then return 0 end

+

+	local pos = mustOpen and 0 or self:tell()

+	C.fseek(self._handle, 0, 2)

+	local size = self:tell()

+	if mustOpen then

+		self:close()

+	else

+		self:seek(pos)

+	end

+	return size

+end

+

+function File:read(containerOrBytes, bytes)

+	if self._mode ~= 'r' then return nil, 0 end

+

+	local container = bytes ~= nil and containerOrBytes or 'string'

+	if container ~= 'string' and container ~= 'data' then

+		error("Invalid container type: " .. container)

+	end

+

+	bytes = not bytes and containerOrBytes or 'all'

+	bytes = bytes == 'all' and self:getSize() - self:tell() or math.min(self:getSize() - self:tell(), bytes)

+

+	if bytes <= 0 then

+		local data = container == 'string' and '' or love.data.newFileData('', self._name)

+		return data, 0

+	end

+

+	local data = love.data.newByteData(bytes)

+	local r = tonumber(C.fread(data:getFFIPointer(), 1, bytes, self._handle))

+

+	local str = data:getString()

+	data:release()

+	data = container == 'data' and love.filesystem.newFileData(str, self._name) or str

+	return data, r

+end

+

+local function lines(file, autoclose)

+	local BUFFERSIZE = 4096

+	local buffer, bufferPos = ByteArray(BUFFERSIZE), 0

+	local bytesRead = tonumber(C.fread(buffer, 1, BUFFERSIZE, file._handle))

+

+	local offset = file:tell()

+	return function()

+		file:seek(offset)

+

+		local line = {}

+		while bytesRead > 0 do

+			for i = bufferPos, bytesRead - 1 do

+				if buffer[i] == 10 then -- end of line

+					bufferPos = i + 1

+					return table.concat(line)

+				end

+

+				if buffer[i] ~= 13 then -- ignore CR

+					table.insert(line, string.char(buffer[i]))

+				end

+			end

+

+			bytesRead = tonumber(C.fread(buffer, 1, BUFFERSIZE, file._handle))

+			offset, bufferPos = offset + bytesRead, 0

+		end

+

+		if not line[1] then

+			if autoclose then file:close() end

+			return nil

+		end

+		return table.concat(line)

+	end

+end

+

+function File:lines()

+	if self._mode ~= 'r' then error("File is not opened for reading") end

+	return lines(self)

+end

+

+function File:write(data, size)

+	if self._mode ~= 'w' and self._mode ~= 'a' then

+		return false, "File " .. self._name .. " not opened for writing"

+	end

+

+	local toWrite, writeSize

+	if type(data) == 'string' then

+		writeSize = (size == nil or size == 'all') and #data or size

+		toWrite = data

+	else

+		writeSize = (size == nil or size == 'all') and data:getSize() or size

+		toWrite = data:getFFIPointer()

+	end

+

+	if tonumber(C.fwrite(toWrite, 1, writeSize, self._handle)) ~= writeSize then

+		return false, "Could not write data"

+	end

+	return true

+end

+

+function File:seek(pos)

+	return self._handle and C.fseek(self._handle, pos, 0) == 0

+end

+

+function File:tell()

+	if not self._handle then return nil, "Invalid position" end

+	return tonumber(C.ftell(self._handle))

+end

+

+function File:flush()

+	if self._mode ~= 'w' and self._mode ~= 'a' then

+		return nil, "File is not opened for writing"

+	end

+	return C.fflush(self._handle) == 0

+end

+

+function File:isEOF()

+	return not self:isOpen() or C.feof(self._handle) ~= 0 or self:tell() == self:getSize()

+end

+

+function File:release()

+	if self._mode ~= 'c' then self:close() end

+	self._handle = nil

+end

+

+function File:type() return 'File' end

+

+function File:typeOf(t) return t == 'File' end

+

+File.__index = File

+

+-----------------------------------------------------------------------------

+

+local nativefs = {}

+local loveC = ffi.os == 'Windows' and ffi.load('love') or C

+

+function nativefs.newFile(name)

+	if type(name) ~= 'string' then

+		error("bad argument #1 to 'newFile' (string expected, got " .. type(name) .. ")")

+	end

+	return setmetatable({

+		_name = name,

+		_mode = 'c',

+		_handle = nil,

+		_bufferSize = 0,

+		_bufferMode = 'none'

+	}, File)

+end

+

+function nativefs.newFileData(filepath)

+	local f = nativefs.newFile(filepath)

+	local ok, err = f:open('r')

+	if not ok then return nil, err end

+

+	local data, err = f:read('data', 'all')

+	f:close()

+	return data, err

+end

+

+function nativefs.mount(archive, mountPoint, appendToPath)

+	return loveC.PHYSFS_mount(archive, mountPoint, appendToPath and 1 or 0) ~= 0

+end

+

+function nativefs.unmount(archive)

+	return loveC.PHYSFS_unmount(archive) ~= 0

+end

+

+function nativefs.read(containerOrName, nameOrSize, sizeOrNil)

+	local container, name, size

+	if sizeOrNil then

+		container, name, size = containerOrName, nameOrSize, sizeOrNil

+	elseif not nameOrSize then

+		container, name, size = 'string', containerOrName, 'all'

+	else

+		if type(nameOrSize) == 'number' or nameOrSize == 'all' then

+			container, name, size = 'string', containerOrName, nameOrSize

+		else

+			container, name, size = containerOrName, nameOrSize, 'all'

+		end

+	end

+

+	local file = nativefs.newFile(name)

+	local ok, err = file:open('r')

+	if not ok then return nil, err end

+

+	local data, size = file:read(container, size)

+	file:close()

+	return data, size

+end

+

+local function writeFile(mode, name, data, size)

+	local file = nativefs.newFile(name)

+	local ok, err = file:open(mode)

+	if not ok then return nil, err end

+

+	ok, err = file:write(data, size or 'all')

+	file:close()

+	return ok, err

+end

+

+function nativefs.write(name, data, size)

+	return writeFile('w', name, data, size)

+end

+

+function nativefs.append(name, data, size)

+	return writeFile('a', name, data, size)

+end

+

+function nativefs.lines(name)

+	local f = nativefs.newFile(name)

+	local ok, err = f:open('r')

+	if not ok then return nil, err end

+	return lines(f, true)

+end

+

+function nativefs.load(name)

+	local chunk, err = nativefs.read(name)

+	if not chunk then return nil, err end

+	return loadstring(chunk, name)

+end

+

+function nativefs.getWorkingDirectory()

+	return getcwd()

+end

+

+function nativefs.setWorkingDirectory(path)

+	if not chdir(path) then return false, "Could not set working directory" end

+	return true

+end

+

+function nativefs.getDriveList()

+	if ffi.os ~= 'Windows' then return { '/' } end

+	local drives, bits = {}, C.GetLogicalDrives()

+	for i = 0, 25 do

+		if bit.band(bits, 2 ^ i) > 0 then

+			table.insert(drives, string.char(65 + i) .. ':/')

+		end

+	end

+	return drives

+end

+

+function nativefs.createDirectory(path)

+	local current = path:sub(1, 1) == '/' and '/' or ''

+	for dir in path:gmatch('[^/\\]+') do

+		current = current .. dir .. '/'

+		local info = nativefs.getInfo(current, 'directory')

+		if not info and not mkdir(current) then return false, "Could not create directory " .. current end

+	end

+	return true

+end

+

+function nativefs.remove(name)

+	local info = nativefs.getInfo(name)

+	if not info then return false, "Could not remove " .. name end

+	if info.type == 'directory' then

+		if not rmdir(name) then return false, "Could not remove directory " .. name end

+		return true

+	end

+	if not unlink(name) then return false, "Could not remove file " .. name end

+	return true

+end

+

+local function withTempMount(dir, fn, ...)

+	local mountPoint = _ptr(loveC.PHYSFS_getMountPoint(dir))

+	if mountPoint then return fn(ffi.string(mountPoint), ...) end

+	if not nativefs.mount(dir, '__nativefs__temp__') then return false, "Could not mount " .. dir end

+	local a, b = fn('__nativefs__temp__', ...)

+	nativefs.unmount(dir)

+	return a, b

+end

+

+function nativefs.getDirectoryItems(dir)

+	if type(dir) ~= "string" then

+		error("bad argument #1 to 'getDirectoryItems' (string expected, got " .. type(dir) .. ")")

+	end

+	local result, err = withTempMount(dir, love.filesystem.getDirectoryItems)

+	return result or {}

+end

+

+local function getDirectoryItemsInfo(path, filtertype)

+	local items = {}

+	local files = love.filesystem.getDirectoryItems(path)

+	for i = 1, #files do

+		local filepath = string.format('%s/%s', path, files[i])

+		local info = love.filesystem.getInfo(filepath, filtertype)

+		if info then

+			info.name = files[i]

+			table.insert(items, info)

+		end

+	end

+	return items

+end

+

+function nativefs.getDirectoryItemsInfo(path, filtertype)

+	if type(path) ~= "string" then

+		error("bad argument #1 to 'getDirectoryItemsInfo' (string expected, got " .. type(path) .. ")")

+	end

+	local result, err = withTempMount(path, getDirectoryItemsInfo, filtertype)

+	return result or {}

+end

+

+local function getInfo(path, file, filtertype)

+	local filepath = string.format('%s/%s', path, file)

+	return love.filesystem.getInfo(filepath, filtertype)

+end

+

+local function leaf(p)

+	p = p:gsub('\\', '/')

+	local last, a = p, 1

+	while a do

+		a = p:find('/', a + 1)

+		if a then

+			last = p:sub(a + 1)

+		end

+	end

+	return last

+end

+

+function nativefs.getInfo(path, filtertype)

+	if type(path) ~= 'string' then

+		error("bad argument #1 to 'getInfo' (string expected, got " .. type(path) .. ")")

+	end

+	local dir = path:match("(.*[\\/]).*$") or './'

+	local file = leaf(path)

+	local result, err = withTempMount(dir, getInfo, file, filtertype)

+	return result or nil

+end

+

+-----------------------------------------------------------------------------

+

+MODEMAP = { r = 'rb', w = 'wb', a = 'ab' }

+local MAX_PATH = 4096

+

+ffi.cdef([[

+	int PHYSFS_mount(const char* dir, const char* mountPoint, int appendToPath);

+	int PHYSFS_unmount(const char* dir);

+	const char* PHYSFS_getMountPoint(const char* dir);

+

+	typedef struct FILE FILE;

+

+	FILE* fopen(const char* path, const char* mode);

+	size_t fread(void* ptr, size_t size, size_t nmemb, FILE* stream);

+	size_t fwrite(const void* ptr, size_t size, size_t nmemb, FILE* stream);

+	int fclose(FILE* stream);

+	int fflush(FILE* stream);

+	size_t fseek(FILE* stream, size_t offset, int whence);

+	size_t ftell(FILE* stream);

+	int setvbuf(FILE* stream, char* buffer, int mode, size_t size);

+	int feof(FILE* stream);

+]])

+

+if ffi.os == 'Windows' then

+	ffi.cdef([[

+		int MultiByteToWideChar(unsigned int cp, uint32_t flags, const char* mb, int cmb, const wchar_t* wc, int cwc);

+		int WideCharToMultiByte(unsigned int cp, uint32_t flags, const wchar_t* wc, int cwc, const char* mb,

+		                        int cmb, const char* def, int* used);

+		int GetLogicalDrives(void);

+		int CreateDirectoryW(const wchar_t* path, void*);

+		int _wchdir(const wchar_t* path);

+		wchar_t* _wgetcwd(wchar_t* buffer, int maxlen);

+		FILE* _wfopen(const wchar_t* path, const wchar_t* mode);

+		int _wunlink(const wchar_t* path);

+		int _wrmdir(const wchar_t* path);

+	]])

+

+	BUFFERMODE = { full = 0, line = 64, none = 4 }

+

+	local function towidestring(str)

+		local size = C.MultiByteToWideChar(65001, 0, str, #str, nil, 0)

+		local buf = ffi.new('wchar_t[?]', size + 1)

+		C.MultiByteToWideChar(65001, 0, str, #str, buf, size)

+		return buf

+	end

+

+	local function toutf8string(wstr)

+		local size = C.WideCharToMultiByte(65001, 0, wstr, -1, nil, 0, nil, nil)

+		local buf = ffi.new('char[?]', size + 1)

+		C.WideCharToMultiByte(65001, 0, wstr, -1, buf, size, nil, nil)

+		return ffi.string(buf)

+	end

+

+	local nameBuffer = ffi.new('wchar_t[?]', MAX_PATH + 1)

+

+	fopen = function(path, mode) return C._wfopen(towidestring(path), towidestring(mode)) end

+	getcwd = function() return toutf8string(C._wgetcwd(nameBuffer, MAX_PATH)) end

+	chdir = function(path) return C._wchdir(towidestring(path)) == 0 end

+	unlink = function(path) return C._wunlink(towidestring(path)) == 0 end

+	mkdir = function(path) return C.CreateDirectoryW(towidestring(path), nil) ~= 0 end

+	rmdir = function(path) return C._wrmdir(towidestring(path)) == 0 end

+else

+	BUFFERMODE = { full = 0, line = 1, none = 2 }

+

+	ffi.cdef([[

+		char* getcwd(char *buffer, int maxlen);

+		int chdir(const char* path);

+		int unlink(const char* path);

+		int mkdir(const char* path, int mode);

+		int rmdir(const char* path);

+	]])

+

+	local nameBuffer = ByteArray(MAX_PATH)

+

+	fopen = C.fopen

+	unlink = function(path) return ffi.C.unlink(path) == 0 end

+	chdir = function(path) return ffi.C.chdir(path) == 0 end

+	mkdir = function(path) return ffi.C.mkdir(path, 0x1ed) == 0 end

+	rmdir = function(path) return ffi.C.rmdir(path) == 0 end

+

+	getcwd = function()

+		local cwd = _ptr(C.getcwd(nameBuffer, MAX_PATH))

+		return cwd and ffi.string(cwd) or nil

+	end

+end

+

+return nativefs

diff --git a/reference.md b/reference.md
index 03ebf62..4dde57d 100644
--- a/reference.md
+++ b/reference.md
@@ -358,16 +358,12 @@ The following facilities help set these things up:
 * `json.decode(obj)` -- turns a JSON string into a Lua object.
   (From [json.lua](https://github.com/rxi/json.lua).)
 
-* `love.filesystem.getDirectoryItems(dir)` -- returns an unsorted array of the
-  files and directories available under `dir`. `dir` must be relative to
-  [LÖVE's save directory](https://love2d.org/wiki/love.filesystem.getSaveDirectory).
-  There is no easy, portable way in Lua/LÖVE to list directories outside the
-  save dir.
+* `App.files(dir)` -- returns an unsorted array of the files and directories
+  available under `dir`.
   (From [LÖVE](https://love2d.org/wiki/love.filesystem.getDirectoryItems).]
 
 * `love.filesystem.getInfo(filename)` -- returns some information about
   `filename`, particularly whether it exists (non-`nil` return value) or not.
-  `filename` must be relative to [LÖVE's save directory](https://love2d.org/wiki/love.filesystem.getSaveDirectory).
   (From [LÖVE](https://love2d.org/wiki/love.filesystem.getInfo).]
 
 * `os.remove(filename)` -- removes a file or empty directory. Definitely make
diff --git a/source_file.lua b/source_file.lua
index 3eaf6c3..d285e0d 100644
--- a/source_file.lua
+++ b/source_file.lua
@@ -10,8 +10,9 @@ function file_exists(filename)
   end
 end
 
+-- the source editor supports only files in the save dir, not even subdirectories
 function load_from_disk(State)
-  local infile = App.open_for_reading(State.filename)
+  local infile = App.open_for_reading(App.save_dir..State.filename)
   State.lines = load_from_file(infile)
   if infile then infile:close() end
 end
@@ -37,7 +38,7 @@ function load_from_file(infile)
 end
 
 function save_to_disk(State)
-  local outfile = App.open_for_writing(State.filename)
+  local outfile = App.open_for_writing(App.save_dir..State.filename)
   if outfile == nil then
     error('failed to write to "'..State.filename..'"')
   end