1 package frost.io
2
3 ====================================================================================================
4 Represents a file path, providing methods to query, read, and write files. A `File` object itself is
5 merely the path to the file, not the actual physical file on disk, so creating a `File` object does
6 not in and of itself cause any interaction with the filesystem.
7
8 `File` always uses `"/"` as a path separator regardless of the operating environment's native path
9 separator. Relative paths (that is, those that do not begin with `"/"`) are interpreted relative to
10 [System.workingDirectory()], which never changes while a program is running.
11 ====================================================================================================
12 class File : Immutable, HashKey<File> {
13 ================================================================================================
14 The path represented by this `File`.
15 ================================================================================================
16 def path:String
17
18 ================================================================================================
19 `true` if this file represents an absolute path.
20 ================================================================================================
21 property isAbsolute:Bit
22
23 ================================================================================================
24 The directory containing this file, or `null` if this file is the root directory or `"."`. The
25 directory is computed based purely on the path string, without accessing the filesystem. Use
26 [parent()] to determine the directory by accessing the filesystem.
27 ================================================================================================
28 property directory:File?
29
30 ================================================================================================
31 The name (not including directory) of this file. For instance, `File("/tmp/data/log.txt").name`
32 is `"log.txt"`.
33 ================================================================================================
34 property name:String
35
36 ================================================================================================
37 The name of this file without its extension. The file's extension is considered to start at the
38 last period (`'.'`) it contains, so `File("/tmp/foo.bar.baz").simpleName()` returns the path
39 `"foo.bar"`. If the filename does not contain a period, this property is equivalen to `name`.
40 ================================================================================================
41 property simpleName:String
42
43 ================================================================================================
44 Creates a new `File` with the specified path. This merely creates a new object representing the
45 path does not access the file system in any way.
46 ================================================================================================
47 init(path:String) {
48 self.path := path
49 }
50
51 ================================================================================================
52 Resolves a path relative to this `File`. If the path begins with `"/"`, it is absolute and
53 returned as-is. Otherwise this `File` is interpreted as a directory containing the relative
54 path, so for instance `File("/tmp/output").relative("data/dump.xml")` results in the path
55 `"/tmp/output/data/dump.xml"`. This function merely performs string manipulation and does not
56 access the filesystem.
57
58 @param path the relative or absolute path
59 @returns the final path
60 ================================================================================================
61 function resolve(path:String):File {
62 if path.startsWith("/") {
63 return File(path)
64 }
65 if path = "." {
66 return self
67 }
68 if self.path.endsWith("/") {
69 return File(self.path + path)
70 }
71 return File(self.path + "/" + path)
72 }
73
74 ================================================================================================
75 Returns an iterator which reads from the file line-by-line. Either `"\r\n"` or `"\n"` is
76 accepted as a line ending, and the line endings are not part of the returned strings.
77
78 @returns the contents of the file line-by-line
79 ================================================================================================
80 function lines():Maybe<Iterator<String>> {
81 try {
82 return Maybe<Iterator<String>>.SUCCESS(openInputStream().lines())
83 }
84 fail(error) {
85 return Maybe<Iterator<String>>.ERROR(error)
86 }
87 }
88
89 function get_isAbsolute():Bit {
90 return path.startsWith("/")
91 }
92
93 -- only guaranteed to work for files which actually exist
94 @private
95 @external(frostFileAbsolute)
96 method systemAbsolutePath():Maybe<File>
97
98 ================================================================================================
99 Returns a `File` representing the absolute path of this file. This queries the filesystem and
100 thus is not guaranteed to succeed.
101
102 @returns the file's absolute path
103 ================================================================================================
104 method absolute():Maybe<File> {
105 if exists() {
106 return systemAbsolutePath()
107 }
108 try {
109 if path.contains("/") {
110 return Maybe<File>.SUCCESS(directory.absolute().resolve(name))
111 }
112 return Maybe<File>.SUCCESS(System.workingDirectory().absolute().resolve(path))
113 }
114 fail(error) {
115 return Maybe<File>.ERROR(error)
116 }
117 }
118
119 @private
120 @class
121 function normalize(path:String):String? {
122 var result := path
123 -- coalesce multiple repeated '/'s into a single '/'
124 result := result.replace(/\/+/, "/")
125 if result = "/" {
126 return result
127 }
128 -- remove trailing '/' or '/.'
129 while result.endsWith("/") | result.endsWith("/.") {
130 result := result[..result.lastIndexOf("/")]
131 }
132 def absolute := result.startsWith("/")
133 if absolute {
134 result := result[1..]
135 }
136 -- collapse "foo/../bar" to just "bar"
137 def components := result.split("/")
138 outer: loop {
139 for i in 1 .. components.count {
140 if components[i] = ".." & components[i - 1] != ".." {
141 components.removeIndex(i)
142 components.removeIndex(i - 1)
143 continue outer
144 }
145 }
146 break
147 }
148 result := components.join("/")
149 if absolute {
150 result := "/" + result
151 if result.contains("/..") {
152 -- uh-oh, we went above root level
153 return null
154 }
155 }
156 if result = "" {
157 result := "."
158 }
159 return result
160 }
161
162 function get_directory():File? {
163 def n := normalize(path)
164 if n = "/" | n = "." | n.endsWith("..") {
165 return null
166 }
167 if !n.contains("/") {
168 return File(".")
169 }
170 def result := normalize(resolve("..").path)
171 if result == null {
172 return null
173 }
174 return File(result)
175 }
176
177 function parent():File? {
178 try {
179 if path.contains("/") {
180 return directory
181 }
182 return absolute().directory
183 }
184 fail(error) {
185 return null
186 }
187 }
188
189 function get_name():String {
190 def index := path.lastIndexOf("/")
191 if index !== null {
192 return path[path.next(index)..]
193 }
194 else {
195 return path
196 }
197 }
198
199 function get_simpleName():String {
200 def result := name
201 def index := result.lastIndexOf(".")
202 if index == null {
203 return result
204 }
205 return result[..index]
206 }
207
208 ================================================================================================
209 Removes the extension (including the period) from this path and replaces it with a new
210 extension. This does not alter the file on disk; it merely performs string manipulation to
211 compute a new path. The new extension does not have to contain a period; it is possible to turn
212 a path with an extension into one without using this method. It is a safety violation to call
213 this on a path which does not have a filename (e.g. `"/"` or `".."`).
214
215 Examples:
216
217 -- testcase FileWithExtension
218 File("/tmp/foo.gif").withExtension(".png") => /tmp/foo.png
219 File("/tmp/foo").withExtension(".png") => /tmp/foo.png
220 File("/tmp/foo.gif").withExtension("") => /tmp/foo
221 File("/tmp/foo.tar.gz").withExtension("") => /tmp/foo.tar
222
223 @param ext the new extension
224 @returns the path with a new extension
225 ================================================================================================
226 function withExtension(ext:String):File {
227 return directory!.resolve(simpleName + ext)
228 }
229
230 ================================================================================================
231 Returns `true` if this file exists. A return value of `false` indicates either that the path
232 does not exist or that an error occurred while trying to query the filesystem.
233
234 @returns `true` if this file exists
235 ================================================================================================
236 @external(frostFileExists)
237 method exists():Bit
238
239 ================================================================================================
240 Returns `true` if this path represents a directory. A return value of `false` indicates either
241 that the path does not exist, is not a directory, or that an error occurred while trying to
242 query the filesystem.
243
244 @returns `true` if this is a directory
245 ================================================================================================
246 @external(frostFileIsDirectory)
247 method isDirectory():Bit
248
249 ================================================================================================
250 Returns a list of files contained by this directory, or an `Error` if the path could not be
251 listed.
252 ================================================================================================
253 @external(frostFileList)
254 method list():Maybe<ListView<File>>
255
256 ================================================================================================
257 Creates a directory at this path. It is not an error to attempt to create a directory which
258 already exists.
259 ================================================================================================
260 @external(frostFileCreateDirectory)
261 method createDirectory():Error?
262
263 ================================================================================================
264 Creates a directory at this path, including all required parent directories. It is not an error
265 to attempt to create a directory which already exists.
266 ================================================================================================
267 method createDirectories():Error? {
268 try {
269 def p := parent()
270 if p !== null {
271 p.createDirectories()
272 }
273 createDirectory()
274 return null
275 }
276 fail(error) {
277 return error
278 }
279 }
280
281 ================================================================================================
282 Returns an `InputStream` for reading this file.
283
284 @returns an `InputStream`
285 ================================================================================================
286 @external(frostFileOpenInputStream)
287 method openInputStream():Maybe<InputStream>
288
289 ================================================================================================
290 Destroys any existing contents of this file and returns an `OutputStream` for writing it. If the
291 file does not already exist, it is created.
292
293 @returns an `OutputStream`
294 ================================================================================================
295 @external(frostFileOpenOutputStream)
296 method openOutputStream():Maybe<OutputStream>
297
298 ================================================================================================
299 Returns an `OutputStream` for writing to the end of this file. If the file does not already
300 exist, it is created.
301
302 @returns an `OutputStream`
303 ================================================================================================
304 @external(frostFileOpenForAppend)
305 method openForAppend():Maybe<OutputStream>
306
307 ================================================================================================
308 Reads the entire contents of the file into memory as a `String` and returns it. Naturally, you
309 should be careful to only call `readFully()` for files which comfortably fit into memory.
310
311 @returns the contents of the file
312 ================================================================================================
313 @priority(1)
314 method readFully():Maybe<String> {
315 try {
316 return Maybe<String>.SUCCESS(openInputStream().readFully())
317 }
318 fail(error) {
319 return Maybe<String>.ERROR(error)
320 }
321 }
322
323 ================================================================================================
324 Reads the entire contents of the file into memory as an `Array<UInt8>` and returns it.
325 Naturally, you should be careful to only call `readFully()` for files which comfortably fit into
326 memory.
327
328 @returns the contents of the file
329 ================================================================================================
330 method readFully():Maybe<Array<UInt8>> {
331 try {
332 return Maybe<Array<UInt8>>.SUCCESS(openInputStream().readFully())
333 }
334 fail(error) {
335 return Maybe<Array<UInt8>>.ERROR(error)
336 }
337 }
338
339 ================================================================================================
340 Writes a string to this path. If the file already exists, its contents are replaced. If it does
341 not exist, it is created.
342
343 @param contents the data to write
344 ================================================================================================
345 method write(contents:String):Error? {
346 try {
347 openOutputStream().print(contents)
348 return null
349 }
350 fail(error) {
351 return error
352 }
353 }
354
355 ================================================================================================
356 Attempts to rename the file to a different path. The rules about when files can be renamed and
357 to which paths depend upon the operating environment, but generally speaking the source and
358 destination paths must be on the same filesystem for this operation to succeed. Returns an
359 [Error] if the file could not be renamed.
360 ================================================================================================
361 @external(frostFileRename)
362 method rename(path:File):Error?
363
364 ================================================================================================
365 Attempts to delete the file. If the file is a directory, it must be empty or the operation will
366 fail. Returns an [Error] if the file could not be deleted.
367 ================================================================================================
368 @external(frostFileDelete)
369 method delete():Error?
370
371 ================================================================================================
372 Returns `true` if both files refer to the same path. Note that two files can refer to the same
373 physical file on disk without being the same path (e.g. `File("src/Foo.frost")` and
374 `File("src/../src/Foo.frost")`); these are considered not equal despite resolving to the same
375 physical file.
376
377 @returns true if the files refer to the same path
378 ================================================================================================
379 @override
380 function =(other:File):Bit {
381 return path = other.path
382 }
383
384 @override
385 function get_hash():Int {
386 return path.hash
387 }
388
389 ================================================================================================
390 Returns the path to which this file refers.
391 ================================================================================================
392 @override
393 function get_toString():String {
394 return path
395 }
396 }