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  }