FileService

Read, write, list, copy, move and recycle files and folders, open a file for ranged work, and follow one that grows.

A service (see Services): call its members with a colon. In a .shot file:

use FileService

In plain Luau:

local FileService = require("@shotsystem/FileService")

Most work goes through a PathHandler, an instance for one path: GetPathHandler(path).

  • A relative path starts at the project file's folder, wherever you run shot run.
  • .Path is the path as given, backslashes turned into /: "box", a backslash and "todo.txt" read back as box/todo.txt.
FileService:GetPathHandler("."):Join("box").Path      ./box
  • A mistake in the code is an error: an empty path or name, an index out of range, a target that is no PathHandler, a destroyed handler, a member called with a dot.
  • On a failure of the world, every disk ...Async member returns nil (or false) and why; ExistsAsync, IsFileAsync, IsFolderAsync and GetFileTypeAsync just answer. A success prints true nil.

Each example is Main.shot of a one-file project, Demo.ssp (see Services), and checks with 0 errors.

Paths and handlers

Naming, no disk:

Path: string, BaseName: stringthe path as given, and its last name
Join(...: string) -> PathHandlera path below this one
Connect(name) -> stringthe same, as a string
GetParent() -> PathHandler?nil for a bare name like "readings.json": use GetPathHandler(".")
GetNameAndExtension() -> (string, string?)"todo.txt" gives todo, txt

Asking:

ExistsAsync(), IsFileAsync(), IsFolderAsync() -> boolean
GetFileTypeAsync() -> "File" | "Folder" | "None"

Reading and writing whole files:

ReadStringAsync() -> (string?, string?)
ReadBufferAsync() -> (buffer?, string?)
OverwriteAsync(content: string | buffer) -> (boolean, string?)   replaces the file, making the folders above it
WriteChildAsync(name, content) -> (boolean, string?)              the same for a file in this folder

Folders:

ListDirAsync() -> ({ [string]: PathHandler }?, string?)   name to PathHandler, in no order: sort the names
FindFirstChildAsync(name) -> (PathHandler?, string?)      nil, nil when there is no such child
CreateSubFolderAsync(name), CreateSubFileAsync(name) -> (PathHandler?, string?)   refused when it exists

Moving and copying (the target is a folder's PathHandler):

CopyAsync(targetParent, newName?) -> (PathHandler?, string?)       a folder copies with everything in it
MoveIntoAsync(targetParent, newName?) -> (PathHandler?, string?)

Recycling and opening (below):

RecycleAsync() -> (RecycledItem?, string?)
OpenAsync() -> (FileHandler?, string?)

A PathHandler has no Parent and no children; Instance.new can't make one.

use FileService

local here = FileService:GetPathHandler(".")
local box, why = here:CreateSubFolderAsync("box")
print("made box:", box ~= nil, why)
print("again:", here:CreateSubFolderAsync("box"))
if not box then
    return
end
local todo = box:Join("todo.txt")
print(todo.Path, todo.BaseName, todo:GetFileTypeAsync())
print(todo:OverwriteAsync("buy milk\n"))
print(box:WriteChildAsync("done.txt", "call Sam\n"))
print(todo:GetFileTypeAsync(), box:GetFileTypeAsync())
print(todo:ReadStringAsync())
print("parent of a bare name:", FileService:GetPathHandler("readings.json"):GetParent())
print("not found:", box:FindFirstChildAsync("nope.txt"))
local list, listWhy = box:ListDirAsync()
if list then
    local names = {}
    for name in list do
        table.insert(names, name)
    end
    table.sort(names)
    print("list:", table.concat(names, ", "))
else
    print("list failed:", listWhy)
end

prints:

made box: true nil
again: nil CreateSubFolderAsync ./box: it exists already
./box/todo.txt todo.txt None
true nil
true nil
File Folder
buy milk
 nil
parent of a bare name: nil
not found: nil nil
list: done.txt, todo.txt

The text's line end puts the reason's nil on the next line.

Copying and moving:

use FileService

local here = FileService:GetPathHandler(".")
local a = assert(here:CreateSubFolderAsync("a"))
local b = assert(here:CreateSubFolderAsync("b"))
assert(a:WriteChildAsync("note.txt", "hello"))
local note = a:Join("note.txt")
print("copy:", (assert(note:CopyAsync(b))).Path)
print("copy again:", note:CopyAsync(b))
local second = assert(note:CopyAsync(b, "note2.txt"))
print("renamed copy:", second.Path)
local moved = assert(note:MoveIntoAsync(b, "moved.txt"))
print("moved:", moved.Path, note:ExistsAsync())
print("into a file:", moved:CopyAsync(second))
print("missing:", note:CopyAsync(b))
local c = assert(b:CopyAsync(here, "c"))
local names = {}
for name in assert(c:ListDirAsync()) do
    table.insert(names, name)
end
table.sort(names)
print("folder copy:", c.Path, table.concat(names, ", "))

prints:

copy: ./b/note.txt
copy again: nil CopyAsync ./a/note.txt: ./b/note.txt exists already
renamed copy: ./b/note2.txt
moved: ./b/moved.txt false
into a file: nil CopyAsync ./b/moved.txt: ./b/note2.txt is no folder
missing: nil CopyAsync ./a/note.txt: no such file or folder
folder copy: ./c moved.txt, note.txt, note2.txt

Files open for ranged work

PathHandler:OpenAsync() -> (FileHandler?, string?) opens a file (a missing one is made empty; a folder is refused). The FileHandler is a child of FileService:

Length: number                                        the size as this handler last saw it
ReadStringAsync(start, finish) -> (string?, string?)  bytes start to finish, 1-based and inclusive, as string.sub
ReadBufferAsync(start, finish) -> (buffer?, string?)
WriteAsync(content, index) -> (boolean, string?)      inserts at index (1 to Length + 1); nothing is overwritten
AppendAsync(content) -> (boolean, string?)

Example:

use FileService

local note = FileService:GetPathHandler("note.txt")
note:OverwriteAsync("hello")
local handle, why = note:OpenAsync()
if not handle then
    error("couldn't open: " .. tostring(why))
end
print(handle.ClassName, handle.Name, handle.Parent == FileService, handle.Length)
print(handle:ReadStringAsync(2, 4))
print(handle:ReadStringAsync(4, 99))
print(handle:WriteAsync(">", 1))
print(handle:AppendAsync("!"))
print(handle.Length, note:ReadStringAsync())
print("bad index:", pcall(function()
    return handle:WriteAsync("x", 99)
end))
print("bad start:", pcall(function()
    return handle:ReadStringAsync(0, 2)
end))
note:OverwriteAsync("hi")
print("after an outside write:", handle.Length)
print(handle:WriteAsync("x", 7))
print(handle:ReadStringAsync(1, 10), handle.Length)
print("open a folder:", FileService:GetPathHandler("."):OpenAsync())
local fresh, freshWhy = FileService:GetPathHandler("new.txt"):OpenAsync()
print("open a missing file:", if fresh then fresh.Length else "nil", freshWhy, FileService:GetPathHandler("new.txt"):ExistsAsync())
handle:Destroy()
print("after Destroy:", pcall(function()
    return handle:ReadStringAsync(1, 1)
end))

prints:

FileHandler note.txt true 5
ell nil
lo nil
true nil
true nil
7 >hello! nil
bad index: false Main.shot:16: WriteAsync: index is a whole number from 1 to Length + 1 (8), got 99
bad start: false Main.shot:19: ReadStringAsync: start is a whole number from 1, got 0
after an outside write: 7
false WriteAsync note.txt: the file has 2 bytes now, fewer than index - 1 (6)
hi 2
open a folder: nil OpenAsync .: it is a folder
open a missing file: 0 nil true
after Destroy: false Main.shot:30: FileHandler "note.txt" was destroyed
  • A range past the end gives what is there (lo).
  • Length updates only when this handler reads or writes: after the outside write it still said 7, the insert was refused, and the next read set it to 2.
  • Destroy the handler when done.

The service's own calls

For a single call, FileService takes path strings:

ReadAsync(path) -> (string?, string?)                 WriteAsync(path, text) -> (boolean, string?)
AppendAsync(path, text) -> (boolean, string?)         SizeAsync(path) -> (number?, string?)
ExistsAsync(path) -> boolean                          IsFolderAsync(path) -> boolean
ListAsync(path) -> ({ FileEntry }?, string?)          MakeFolderAsync(path) -> (boolean, string?)
CopyAsync(from, to, overwrite?) -> (boolean, string?) MoveAsync(from, to, overwrite?) -> (boolean, string?)
RemoveAsync(path) -> (boolean, string?)               ReadFromAsync(path, offset) -> (string?, string?)
  • WriteAsync and AppendAsync make missing folders; AppendAsync makes a missing file.
  • ListAsync's entries are { Name, IsFolder, Modified }, sorted by name; Modified is seconds since 1970, or nil.
  • ReadFromAsync gives the text after offset bytes; past the end:
nil ReadFromAsync notes/todo.txt: the file has 18 bytes, fewer than the offset 99
  • CopyAsync and MoveAsync refuse an existing target unless overwrite is true:
false CopyAsync notes: A directory already exists at the path 'notes2'
  • RemoveAsync deletes for good, a folder with everything in it; RecycleAsync can be undone.

Following a file

FileService:Follow(path, offset?, interval?) -> FileFollower follows a growing file, such as a log. It returns at once, a child of FileService, and looks every interval seconds (0.5 by default).

  • Text fires with each added part, in order (a part can hold several lines).
  • Line fires with each whole line, without its line end; a half-written line waits.
  • Reset fires when the file got shorter; reading starts over, so Text brings the new content.
  • Offset is how far it has read. An offset of 0 starts at the beginning, nil at the end (Offset then reads 0 until the first look).
  • Destroy() ends it. Until then the program keeps running.
  • A negative offset is an error:
Follow: offset is a whole number of bytes from 0 or nil, got -1

Example:

use FileService
use TimeService

local log = FileService:GetPathHandler("app.log")
log:OverwriteAsync("")
local follow = FileService:Follow("app.log", 0, 0.1)
print(follow.ClassName, follow.Name, follow.Parent == FileService, follow.Path)
follow.Text:Connect(function(added : string)
    print("added: [" .. added .. "] offset " .. follow.Offset)
end)
follow.Reset:Connect(function()
    print("the file got shorter")
end)
for step = 1, 3 do
    FileService:AppendAsync("app.log", "step " .. step .. "\n")
    TimeService:WaitAsync(0.3)
end
FileService:AppendAsync("app.log", "a\nb\n")
TimeService:WaitAsync(0.3)
log:OverwriteAsync("x\n")
TimeService:WaitAsync(0.3)
print("offset before Destroy " .. follow.Offset)
follow:Destroy()
FileService:AppendAsync("app.log", "after\n")
TimeService:WaitAsync(0.3)
print("done")

prints:

FileFollower app.log true app.log
added: [step 1
] offset 7
added: [step 2
] offset 14
added: [step 3
] offset 21
added: [a
b
] offset 25
the file got shorter
added: [x
] offset 2
offset before Destroy 2
done

Line, with a line written in two parts:

use FileService
use TimeService

FileService:WriteAsync("app.log", "")
local follow = FileService:Follow("app.log", 0)
follow.Line:Connect(function(line : string)
    print("line: [" .. line .. "] offset " .. follow.Offset)
end)
follow.Text:Connect(function(added : string)
    print("text: " .. #added .. " bytes")
end)
FileService:AppendAsync("app.log", "a\nb\nhalf")
TimeService:WaitAsync(0.8)
FileService:AppendAsync("app.log", " done\n")
TimeService:WaitAsync(0.8)
follow:Destroy()

prints:

line: [a] offset 2
line: [b] offset 4
text: 8 bytes
line: [half done] offset 14
text: 6 bytes

Each look reads the whole file again.

The recycle bin

PathHandler:RecycleAsync() -> (RecycledItem?, string?) moves a file or folder into .ShotRecycleBin at its drive's root. A RecycledItem has OriginalPath (as given), Path (its PathHandler in the bin), RecycledAt (seconds since 1970) and:

RestoreAsync() -> (PathHandler?, string?)back to OriginalPath; refused when something is there now
DeleteAsync() -> (boolean, string?)deletes it for good

Example:

use FileService

local note = FileService:GetPathHandler("draft.txt")
note:OverwriteAsync("first draft")
local item, why = note:RecycleAsync()
if not item then
    error("couldn't recycle: " .. tostring(why))
end
print(item.ClassName, item.Name, item.OriginalPath, item.Path ~= nil, item.Parent)
print(note:ExistsAsync(), item.RecycledAt > 0)
local back, backWhy = item:RestoreAsync()
print("restore:", if back then back.Path else "nil", backWhy, note:ReadStringAsync())
local second = note:RecycleAsync()
note:OverwriteAsync("second draft")
if second then
    print("restore onto a file:", second:RestoreAsync())
    print("delete:", second:DeleteAsync())
end
print("recycle nothing:", FileService:GetPathHandler("nothing.txt"):RecycleAsync())

prints:

RecycledItem draft.txt draft.txt true nil
false true
restore: draft.txt nil first draft nil
restore onto a file: nil RestoreAsync draft.txt: something is there now
delete: true nil
recycle nothing: nil RecycleAsync nothing.txt: no such file or folder

One queue per path

Calls on one path take turns, in order, so writers started at once keep each other's text. Another case or the other slash is the same path.

use FileService

FileService:WriteAsync("count.log", "")
for n = 1, 50 do
    task.spawn(function()
        FileService:AppendAsync("count.log", n .. "\n")
    end)
end
local text = FileService:ReadAsync("count.log") or ""
local lines = string.split(text, "\n")
print(#lines - 1, lines[1], lines[2], lines[50])

prints 50 1 2 50: all fifty lines in order; the read waited its turn.

Mistakes

A dot call (Services, REACH A SERVICE) and Instance.new (Instances, MAKING INSTANCES):

use FileService

print(pcall(function()
    return FileService.ReadAsync("notes.txt")
end))
print(pcall(function()
    return FileService.GetPathHandler("notes.txt")
end))
print(pcall(function()
    return Instance.new("PathHandler")
end))

shot check --project Demo.ssp prints:

Demo.ssp/Main.shot:4:9   TypeError: This function must be called with self. Did you mean to use a colon instead of a dot?
Demo.ssp/Main.shot:7:9   TypeError: This function must be called with self. Did you mean to use a colon instead of a dot?
Demo.ssp/Main.shot:10:9   PathHandler can't be made with Instance.new: FileService:GetPathHandler returns one
3 errors in 1 file (1 files checked, 0.4 s)

and each errors at run time:

false Main.shot:4: FileService.ReadAsync is a method: call it with a colon, FileService:ReadAsync(...)
false Main.shot:7: FileService.GetPathHandler is a method: call it with a colon, FileService:GetPathHandler(...)
false Main.shot:10: PathHandler can't be made with Instance.new: FileService:GetPathHandler(path) gives one

Failures of the world, not errors:

missing:ReadStringAsync()nil ReadStringAsync missing.txt: no such file
here:ReadStringAsync()nil ReadStringAsync .: access denied
here:OverwriteAsync("x")false OverwriteAsync .: access denied
nowhere:ListDirAsync()nil ListDirAsync nowhere: no such folder

shot check refuses a value that may be nil used as text or a table:

use FileService

local todo = FileService:GetPathHandler("box/todo.txt")
print(todo:GetParent().Path)

gives

Demo.ssp/Main.shot:4:7   TypeError: Value of type 'PathHandler?' could be nil

An empty path is an error:

GetPathHandler takes a path (a non-empty string), got string