module std::io::path; import std::collections::list, std::io::os; import std::os::win32; const PathEnv DEFAULT_ENV = env::WIN32 ? PathEnv.WIN32 : PathEnv.POSIX; const char PREFERRED_SEPARATOR_WIN32 = '\\'; const char PREFERRED_SEPARATOR_POSIX = '/'; const char PREFERRED_SEPARATOR = env::WIN32 ? PREFERRED_SEPARATOR_WIN32 : PREFERRED_SEPARATOR_POSIX; alias PathList = List { Path }; faultdef INVALID_PATH, NO_PARENT; alias Path = PathImp; struct PathImp (Printable) { String path_string; PathEnv env; Allocator allocator; } enum PathEnv { WIN32, POSIX } fn Path? cwd(Allocator allocator) { @pool() { return new(allocator, os::getcwd(tmem)); }; } fn bool is_dir(Path path) => os::native_is_dir(path.str_view()); fn bool is_file(Path path) => os::native_is_file(path.str_view()); fn usz? file_size(Path path) => os::native_file_size(path.str_view()); fn bool exists(Path path) => os::native_file_or_dir_exists(path.str_view()); fn Path? tcwd() => cwd(tmem) @inline; <* @require @is_pathlike(path) : "Expected a Path or String to chdir" *> macro void? chdir(path) { $if $typeof(path) == String: @pool() { return os::native_chdir(temp(path)); }; $else return os::native_chdir(path) @inline; $endif } fn Path? temp_directory(Allocator allocator) => os::native_temp_directory(allocator); fn Path? home_directory(Allocator allocator) => os::native_home_directory(allocator); fn Path? desktop_directory(Allocator allocator) => os::native_user_directory(allocator, DESKTOP); fn Path? videos_directory(Allocator allocator) => os::native_user_directory(allocator, VIDEOS); fn Path? music_directory(Allocator allocator) => os::native_user_directory(allocator, MUSIC); fn Path? documents_directory(Allocator allocator) => os::native_user_directory(allocator, DOCUMENTS); fn Path? screenshots_directory(Allocator allocator) => os::native_user_directory(allocator, SCREENSHOTS); fn Path? saved_games_directory(Allocator allocator) => os::native_user_directory(allocator, SAVED_GAMES); fn Path? downloads_directory(Allocator allocator) => os::native_user_directory(allocator, DOWNLOADS); fn Path? pictures_directory(Allocator allocator) => os::native_user_directory(allocator, PICTURES); fn Path? templates_directory(Allocator allocator) => os::native_user_directory(allocator, TEMPLATES); fn Path? public_share_directory(Allocator allocator) => os::native_user_directory(allocator, PUBLIC_SHARE); fn void? delete(Path path) => os::native_remove(path.str_view()) @inline; macro bool @is_pathlike(#path) @const => $typeof(#path) == String ||| $typeof(#path) == Path; macro bool is_separator(char c, PathEnv path_env = DEFAULT_ENV) { return c == '/' || (c == '\\' && path_env == PathEnv.WIN32); } macro bool is_posix_separator(char c) => c == '/'; macro bool is_win32_separator(char c) => c == '/' || c == '\\'; fn PathList? ls(Allocator allocator, Path dir, bool no_dirs = false, bool no_symlinks = false, String mask = "") { $if $defined(os::native_ls): return os::native_ls(dir, no_dirs, no_symlinks, mask, allocator); $else return io::UNSUPPORTED_OPERATION~; $endif } enum MkdirPermissions { NORMAL, USER_ONLY, USER_AND_ADMIN } <* Create a directory on a given path, optionally recursive. @param path : `The path to create` @require @is_pathlike(path) : "Expected a Path or String to chdir" @param recursive : `If directories in between should be created if they're missing, defaults to false` @param permissions : `The permissions to set on the directory` *> macro bool? mkdir(path, bool recursive = false, MkdirPermissions permissions = NORMAL) { $if $typeof(path) == String: @pool() { return _mkdir(temp(path), recursive, permissions); }; $else return _mkdir(path, recursive, permissions); $endif } <* Tries to delete directory, which must be empty. @param path : `The path to delete` @require @is_pathlike(path) : "Expected a Path or String to chdir" @return `true if there was a directory to delete, false otherwise` @return? INVALID_PATH : `if the path was invalid` *> macro bool? rmdir(path) { $if $typeof(path) == String: @pool() { return _rmdir(temp(path)); }; $else return _mkdir(path); $endif } <* Like [rmdir] but deletes a directory even if it contains items. *> fn void? rmtree(Path path) { if (!path.path_string.len) return INVALID_PATH~; $if $defined(os::native_rmtree): return os::native_rmtree(path); $else return io::UNSUPPORTED_OPERATION~; $endif } <* Creates a new path. @return? INVALID_PATH : `if the path was invalid` *> fn Path? new(Allocator allocator, String path, PathEnv path_env = DEFAULT_ENV) { return { normalize(path.copy(allocator), path_env), path_env, allocator }; } <* Creates a new path using the temp allocator. @return? INVALID_PATH : `if the path was invalid` *> fn Path? temp(String path, PathEnv path_env = DEFAULT_ENV) => new(tmem, path, path_env); fn Path? from_wstring(Allocator allocator, WString path) => @pool() { return path::new(allocator, string::tfrom_wstring(path)!); } fn Path? from_win32_wstring(Allocator allocator, WString path) @deprecated("Use 'from_wstring' instead") => from_wstring(allocator, path); fn Path? for_windows(Allocator allocator, String path) => new(allocator, path, WIN32); fn Path? for_posix(Allocator allocator, String path) => new(allocator, path, POSIX); fn bool Path.equals(self, Path p2) @operator(==) { return self.env == p2.env && self.path_string == p2.path_string; } <* Append the string to the current path. @param [in] filename *> fn Path? Path.append(self, Allocator allocator, String filename) { if (!self.path_string.len) return new(allocator, filename, self.env)!; @pool() { DString dstr = dstring::temp_with_capacity(self.path_string.len + 1 + filename.len); dstr.append(self.path_string); if (!is_separator(self.path_string[^1], self.env)) dstr.append(PREFERRED_SEPARATOR); dstr.append(filename); return new(allocator, dstr.str_view(), self.env); }; } fn Path? Path.tappend(self, String filename) => self.append(tmem, filename); fn usz? start_of_base_name(String str, PathEnv path_env) @local { if (!str.len) return 0; usz? start_slash = str.rindex_of_char('/'); if (path_env != PathEnv.WIN32) return start_slash + 1 ?? 0; if (try index = str.rindex_of_char('\\')) { if (try start_slash && start_slash > index) return start_slash + 1; // c:\ style path, we're done! if (str[0] != '\\') return index + 1; // Handle \\server\foo // Find the \ before "foo" usz last_index = 2 + str[2..].index_of_char('\\')!; // If they don't match, we're done if (last_index > index) return INVALID_PATH~; if (last_index != index) return index + 1; // Otherwise just default to the volume length. } return volume_name_len(str, path_env)!!; } fn bool? String.is_absolute_path(self) => @pool() { return temp(self).is_absolute(); } fn bool? Path.is_absolute(self) { String path_str = self.str_view(); if (!path_str.len) return false; usz path_start = volume_name_len(path_str, self.env)!; if (path_start > 0 && path_str[0] == '\\') return true; return path_start < path_str.len && is_separator(path_str[path_start], self.env); } fn Path? String.to_absolute_path(self, Allocator allocator) => @pool() { return temp(self).absolute(allocator); } <* @require self.env == DEFAULT_ENV : "This method is only available on native paths" *> fn Path? Path.absolute(self, Allocator allocator) { String path_str = self.str_view(); if (!path_str.len) return INVALID_PATH~; if (self.is_absolute()!) return new(allocator, path_str, self.env); if (path_str == ".") { @pool() { String cwd = os::getcwd(tmem)!; return new(allocator, cwd, self.env); }; } $if DEFAULT_ENV == WIN32: @pool() { const usz BUFFER_LEN = 4096; WString buffer = (WString)mem::talloc_array(Char16, BUFFER_LEN); buffer = win32::_wfullpath(buffer, path_str.to_temp_wstring()!, BUFFER_LEN); if (!buffer) return INVALID_PATH~; return { string::from_wstring(allocator, buffer), WIN32, allocator }; }; $else String cwd = os::getcwd(tmem)!; return (Path){ cwd, self.env, tmem }.append(allocator, path_str)!; $endif } fn String? String.file_basename(self, Allocator allocator) => @pool() { return temp(self).basename().copy(allocator); } fn String? String.file_tbasename(self) => self.file_basename(tmem); fn String Path.basename(self) { usz basename_start = start_of_base_name(self.path_string, self.env)!!; String path_str = self.path_string; if (basename_start == path_str.len) return ""; return path_str[basename_start..]; } fn String? String.path_tdirname(self) => self.path_dirname(tmem); fn String? String.path_dirname(self, Allocator allocator) => @pool() { return temp(self).dirname().copy(allocator); } fn String Path.dirname(self) { String path_str = self.path_string; usz basename_start = start_of_base_name(path_str, self.env)!!; if (basename_start == 0) return "."; usz start = volume_name_len(path_str, self.env)!!; if (basename_start <= start + 1) { if (self.env == WIN32 && basename_start > start && path_str[0..1] == `\\`) { return path_str[:basename_start - 1]; } return path_str[:basename_start]; } return path_str[:basename_start - 1]; } <* Test if the path has the given extension, so given the path /foo/bar.c3 this would be true matching the extension "c3" @param [in] extension : `The extension name (not including the leading '.')` @require extension.len > 0 : `The extension cannot be empty` @return `true if the extension matches` *> fn bool Path.has_extension(self, String extension) { String basename = self.basename(); if (basename.len <= extension.len) return false; if (basename[^extension.len + 1] != '.') return false; return basename[^extension.len..] == extension; } fn String? Path.extension(self) { String basename = self.basename(); usz index = basename.rindex_of(".")!; // Plain ".foo" does not have an extension if (index == 0 || index == basename.len) return ""; return basename[index + 1..]; } fn String Path.volume_name(self) { usz len = volume_name_len(self.str_view(), self.env)!!; if (!len) return ""; return self.path_string[:len]; } fn Path? String.to_path(self, Allocator allocator) => new(allocator, self); fn Path? String.to_tpath(self) => new(tmem, self); fn usz? volume_name_len(String path, PathEnv path_env) @local { usz len = path.len; if (len < 2 || path_env != PathEnv.WIN32) return 0; switch (path[0]) { case '\\': // "\\" paths.. must be longer than 2 if (len == 2) return 0; int count = 1; while (count < len && path[count] == '\\') count++; // Not 2 => folded paths if (count != 2) return 0; // Check that we have a name followed by '\' isz base_found = 0; for (usz i = 2; i < len; i++) { char c = path[i]; if (is_win32_separator(c)) { if (base_found) return i; base_found = i; continue; } if (is_reserved_win32_path_char(c)) return INVALID_PATH~; } if (base_found > 0 && base_found + 1 < len) return len; return INVALID_PATH~; case 'A'..'Z': case 'a'..'z': return path[1] == ':' ? 2 : 0; default: return 0; } } <* Get the path of the parent. This does not allocate, but returns a slice of the path itself. @return `The parent of the path as a non-allocated path` @return? NO_PARENT : `if this path does not have a parent` *> fn Path? Path.parent(self) { if (self.path_string.len == 1 && is_separator(self.path_string[0], self.env)) return NO_PARENT~; foreach_r(i, c : self.path_string) { if (is_separator(c, self.env)) { if (i == 0) return { self.path_string[..0], self.env, null }; if (self.env == WIN32 && i > 1) { if (try volume_len = volume_name_len(self.path_string, WIN32)) { // Handle C:\foo if (volume_len == i) { if (i + 1 == self.path_string.len) return NO_PARENT~; return { self.path_string[:i + 1], WIN32, null }; } } } return { self.path_string[:i], self.env, null }; } } return NO_PARENT~; } fn String? normalize(String path_str, PathEnv path_env = DEFAULT_ENV) { if (!path_str.len) return path_str; usz path_start = volume_name_len(path_str, path_env)!; if (path_start > 0 && path_env == PathEnv.WIN32) { for (usz i = 0; i < path_start; i++) if (path_str[i] == '/') path_str[i] = '\\'; } usz path_len = path_str.len; if (path_start == path_len) return path_str; char path_separator = path_env == PathEnv.WIN32 ? PREFERRED_SEPARATOR_WIN32 : PREFERRED_SEPARATOR_POSIX; usz len = path_start; bool has_root = is_separator(path_str[path_start], path_env); if (has_root) { path_str[len++] = path_separator; path_start++; } // It is safe to write it as true, since we already dealt with /foo. // This allows us to avoid checking whether it is the start of the path. bool previous_was_separator = true; for (usz i = path_start; i < path_len; i++) { char c = path_str[i]; // Fold foo///bar into foo/bar if (is_separator(c, path_env)) { // Fold // if (previous_was_separator) continue; // New /, so mark and rewrite path_str.ptr[len++] = path_separator; previous_was_separator = true; continue; } // The rest are names of the path elements, so check that the // characters are valid. if (is_reserved_path_char(c, path_env)) return INVALID_PATH~; // If we have '.' after a separator if (c == '.' && previous_was_separator) { // Get the number of dots until next separator, expecting 1 or 2 bool is_last = i == path_len - 1; int dots = 1; if (!is_last) { char next = path_str[i + 1]; switch { case next == '.': dots = 2; is_last = i == path_len - 2; if (!is_last && !is_separator(path_str[i + 2], path_env)) { dots = 0; } case !is_separator(next, path_env): dots = 0; } } switch (dots) { case 1: // /./abc -> skip to /./abc // ^ ^ i++; continue; case 2: // This is an error: /a/../.. if (len == path_start && has_root) return INVALID_PATH~; // If this .. at the start, or after ../? If so, we just copy .. if (len == path_start || (len - path_start >= 3 && path_str[len - 1] == path_separator && path_str[len - 3] == '.' && path_str[len - 3] == '.' && (len - 3 == 0 || path_str[len - 4] == path_separator))) { if (i != len) { path_str[len] = '.'; path_str[len + 1] = '.'; } len += 2; if (len < path_len) path_str[len++] = path_separator; i += 2; continue; } // Step back, now looking at '/' abc/def/. -> abc/def/ len--; // Step back until finding a separator or the start. while (len > path_start && !is_separator(path_str[len - 1], path_env)) { len--; } // Reading, we go from /../abc to /../abc // ^ ^ i += 2; continue; default: break; } } if (i != len) path_str[len] = c; previous_was_separator = false; len++; } if (len > path_start + 1 && is_separator(path_str[len - 1], path_env)) len--; if (path_str.len > len) path_str.ptr[len] = 0; // Empty path after normalization -> "." if (!len) { path_str[0] = '.'; return path_str[:1]; } return path_str[:len]; } fn ZString Path.as_zstr(self) @deprecated => (ZString)self.path_string.ptr; fn String Path.root_directory(self) { String path_str = self.str_view(); usz len = path_str.len; if (!len) return ""; if (path_str == ".") return "."; if (self.env == PathEnv.WIN32) { usz root_len = volume_name_len(path_str, self.env)!!; if (root_len == len || !is_win32_separator(path_str[root_len])) return ""; return path_str[root_len..root_len]; } if (!is_posix_separator(path_str[0])) return ""; for (usz i = 1; i < len; i++) { if (is_posix_separator(path_str[i])) { return path_str[:i]; } } return path_str; } alias PathWalker = fn bool? (Path, bool is_dir, void*); <* Walk the path recursively. PathWalker is run on every file and directory found. Return true to abort the walk. @require self.env == DEFAULT_ENV : "This method is only available on native paths" *> fn bool? Path.walk(self, PathWalker w, void* data) { const PATH_MAX = 512; @stack_mem(PATH_MAX; Allocator allocator) { Path abs = self.absolute(allocator)!; PathList files = ls(allocator, abs)!; foreach (f : files) { if (f.str_view() == "." || f.str_view() == "..") continue; f = abs.append(allocator, f.str_view())!; bool is_directory = is_dir(f); if (w(f, is_directory, data)!) return true; if (is_directory && f.walk(w, data)!) return true; } }; return false; } alias TraverseCallback = fn bool? (Path, bool is_dir, any data); <* Walk the path recursively. TraverseCallback is run for every file and directory found. Return true to abort the walk. @require path.env == DEFAULT_ENV : "This method is only available on native paths" *> fn bool? traverse(Path path, TraverseCallback callback, any data) { const PATH_MAX = 512; @stack_mem(PATH_MAX; Allocator allocator) { Path abs = path.absolute(allocator)!; PathList files = ls(allocator, abs)!; foreach (f : files) { if (f.str_view() == "." || f.str_view() == "..") continue; @stack_mem(128; Allocator smem) { f = abs.append(smem, f.str_view())!; bool is_directory = is_dir(f); if (callback(f, is_directory, data)!) return true; if (is_directory && traverse(f, callback, data)!) return true; }; } }; return false; } fn String Path.str_view(self) @inline => self.path_string; fn bool Path.has_suffix(self, String str) => self.str_view().ends_with(str); <* @require self.allocator != null : "This Path should never be freed" *> fn void Path.free(self) => allocator::free(self.allocator, self.path_string.ptr); fn usz? Path.to_format(&self, Formatter* formatter) @dynamic => formatter.print(self.str_view()); const bool[256] RESERVED_PATH_CHAR_POSIX = { [0] = true, ['/'] = true, }; const bool[256] RESERVED_PATH_CHAR_WIN32 = { [0..31] = true, ['>'] = true, ['<'] = true, [':'] = true, ['\"'] = true, ['/'] = true, ['\\'] = true, ['|'] = true, ['?'] = true, ['*'] = true, }; macro bool is_reserved_win32_path_char(char c) => RESERVED_PATH_CHAR_WIN32[c]; macro bool is_reserved_path_char(char c, PathEnv path_env = DEFAULT_ENV) { return path_env == PathEnv.WIN32 ? RESERVED_PATH_CHAR_WIN32[c] : RESERVED_PATH_CHAR_POSIX[c]; } fn bool? _mkdir(Path path, bool recursive = false, MkdirPermissions permissions = NORMAL) @private { if (!path.path_string.len) return INVALID_PATH~; if (is_dir(path)) return false; if (exists(path)) return io::FILE_NOT_DIR~; if (recursive) { if (try parent = path.parent()) mkdir(parent, true, permissions)!; } if (!is_dir(path.parent()) ?? false) return io::CANNOT_READ_DIR~; return os::native_mkdir(path, permissions); } fn bool? _rmdir(Path path) @private { if (!path.path_string.len) return INVALID_PATH~; return os::native_rmdir(path); }