import gleam/io import gleam/list import gleam/map import gleam/string import gleam/string_builder import gleeunit2 import shellout /// Let's Gleam switch code based on the current target. /// type Target { ErlangTarget JavaScriptTarget } /// Atom to internally differentiate between Src and Test modules. /// Public because the FFIs rely on it. /// pub type ModuleKind { SrcModuleKind TestModuleKind } /// Colour options for shellout /// const shellout_lookups: shellout.Lookups = [ #(["color", "background"], [#("lightblue", ["156", "231", "255"])]), ] /// Runs either gleeunit or glacier depending on given flags /// pub fn main() { let start_args = start_args() let is_incremental = list.contains(start_args, "--glacier") let is_empty_args = start_args == [] case is_empty_args, is_incremental { True, _ -> gleeunit2.main() _, True -> { "๐Ÿ” Glacier is watching for changesโ€ฆ" |> shellout.style( with: shellout.display(["italic"]) |> map.merge(shellout.color(["lightblue"])), custom: shellout_lookups, ) |> io.println start_file_change_watcher(fn(modules: List(#(ModuleKind, String))) -> Nil { execute_tests(modules) }) } _, _ -> gleeunit2.run(for: start_args) } } /// Executes tests /// fn execute_tests(modules: List(#(ModuleKind, String))) { let test_modules = list.fold( over: modules, from: [], with: fn(test_modules_acc: List(String), module: #(ModuleKind, String)) { let module_kind = module.0 let full_module_path = module.1 let test_modules = case module_kind { SrcModuleKind -> detect_distinct_import_module_dependency_chain( [file_name_to_module_name(full_module_path, SrcModuleKind)], [], ) |> derive_test_modules_from_src_import_dependencies() |> list.map(fn(test_module) { "test/" <> test_module <> ".gleam" }) TestModuleKind -> [to_relative_path(full_module_path)] } list.append(test_modules_acc, test_modules) }, ) |> list.unique() case test_modules { [] -> { "๐Ÿ” Did not detect any matching test modules!" |> shellout.style( with: shellout.display(["bold"]) |> map.merge(shellout.color(["lightblue"])), custom: shellout_lookups, ) |> io.println Nil } test_modules -> { "๐Ÿ” Detected test modules:" |> shellout.style( with: shellout.display(["bold"]) |> map.merge(shellout.color(["lightblue"])), custom: shellout_lookups, ) |> io.println list.map( test_modules, with: fn(test_module: String) { " โ„ " <> test_module }, ) |> string.join("\n") |> shellout.style( with: shellout.color(["lightblue"]), custom: shellout_lookups, ) |> io.println let args = [ "test", "--target", case target() { ErlangTarget -> "erlang" JavaScriptTarget -> "javascript" }, "--", ..test_modules ] case shellout.command( run: "gleam", with: args, in: ".", opt: [shellout.LetBeStdout, shellout.LetBeStderr], ) { Ok(msg) -> { io.print(msg) Nil } Error(_error_tuple) -> Nil } } } } /// Starts the file watcher /// fn start_file_change_watcher( file_change_handler: fn(List(#(ModuleKind, String))) -> Nil, ) -> Nil { do_start_file_change_watcher(file_change_handler) Nil } /// Detects distinct import module dependency chain /// fn detect_distinct_import_module_dependency_chain( module_names: List(String), processed_module_names: List(String), ) -> List(String) { case module_names { [] -> processed_module_names [module_name, ..rest_module_names] -> case list.contains(processed_module_names, module_name) { True -> detect_distinct_import_module_dependency_chain( rest_module_names, processed_module_names, ) False -> { let unchecked_module_names = module_name |> module_name_to_file_name(SrcModuleKind) |> parse_module_for_imports |> list.append(module_names) |> list.filter(for: fn(module_name) { list.contains(processed_module_names, module_name) == False && file_exists(module_name_to_file_name( module_name, SrcModuleKind, )) }) detect_distinct_import_module_dependency_chain( list.append(rest_module_names, unchecked_module_names), list.append(processed_module_names, [module_name]), ) } } } } /// Parses a module file for its import statements /// fn parse_module_for_imports(module_file_name: String) -> List(String) { module_file_name |> fn(module_file) { // io.debug(#("module_file", module_file)) module_file } |> read_module_file() |> string.to_graphemes() |> parse_module_string([], ParseModeSearch, "") |> list.unique() } type ParseMode { ParseModeInComment ParseModeInString ParseModeSearch } /// Parses a module string for its import statements /// fn parse_module_string( chars: List(String), imports: List(String), context: ParseMode, collected: String, ) -> List(String) { case chars { [] -> imports [char, ..rest_chars] -> // io.debug(#(context, collected, char)) case context, collected, char { // Found `/`: Continue Initial with / in collected ParseModeSearch, "", "/" -> parse_module_string(rest_chars, imports, ParseModeSearch, "/") // Found `/` + `/`: Enter Comment ParseModeSearch, "/", "/" -> parse_module_string(rest_chars, imports, ParseModeInComment, "") // Found `"`: Enter String ParseModeSearch, _collected, "\"" -> parse_module_string(rest_chars, imports, ParseModeInString, "") // Collecting import keyword: Continue Initial ParseModeSearch, collected, char if collected == "" && char == "i" || collected == "i" && char == "m" || collected == "im" && char == "p" || collected == "imp" && char == "o" || collected == "impo" && char == "r" || collected == "impor" && char == "t" -> parse_module_string( rest_chars, imports, ParseModeSearch, collected <> char, ) // Found `import` + whitespaceish: Enter Import ParseModeSearch, "import", char if char == " " || char == "\t" || char == "\n" || char == "\r\n" -> { let #(rest_chars, new_import) = parse_import_chars(rest_chars, string_builder.new()) let new_import = string_builder.to_string(new_import) // io.debug(#("detected import", new_import)) let updated_imports = [new_import, ..imports] parse_module_string(rest_chars, updated_imports, ParseModeSearch, "") } // Found `import\r` + "\n": Enter Import ParseModeSearch, "import\r", "\n" -> { let #(rest_chars, new_import) = parse_import_chars(rest_chars, string_builder.new()) let imports = [string_builder.to_string(new_import), ..imports] parse_module_string(rest_chars, imports, ParseModeSearch, "") } // Found whitespaceish char: Continue Initial with empty collected ParseModeSearch, _collected, _char -> parse_module_string(rest_chars, imports, ParseModeSearch, "") // In Comment; found `\n`: Exit Comment ParseModeInComment, _collected, "\n" -> parse_module_string(rest_chars, imports, ParseModeSearch, "") // In Comment; found `\n`: Exit Comment ParseModeInComment, _collected, "\r" -> parse_module_string(rest_chars, imports, ParseModeSearch, "") ParseModeInComment, _collected, "\r\n" -> parse_module_string(rest_chars, imports, ParseModeSearch, "") // In Comment; found any other char: Continue Comment ParseModeInComment, _collected, _any -> parse_module_string(rest_chars, imports, ParseModeInComment, "") // In String; escape found char: Continue String ParseModeInString, "\\", _escaped -> parse_module_string(rest_chars, imports, ParseModeInString, "") // In String; found `"`: Exit String ParseModeInString, _collected, "\"" -> parse_module_string(rest_chars, imports, ParseModeSearch, "") // In String; found a single `\`: Continue String with collected set to `\` ParseModeInString, _collected, "\\" -> parse_module_string(rest_chars, imports, ParseModeInString, "\\") // In String; found any other char: Continue String with empty collected ParseModeInString, _collected, _char -> parse_module_string(rest_chars, imports, ParseModeInString, "") } } } /// Parses an import statement /// fn parse_import_chars( chars: List(String), import_module: string_builder.StringBuilder, ) { // TODO: try pop grapheme case chars { // Return if end of line [] -> #([], import_module) // Return if \r\n ["\r\n", ..rest_chars] -> #(rest_chars, import_module) // Return if \n ["\n", ..rest_chars] -> #(rest_chars, import_module) // Ignore whitespaces [char, ..rest_chars] if char == " " || char == "\t" || char == "\r" || char == "\n" || char == "\r\n" -> parse_import_chars(rest_chars, import_module) // Append for any other character [char, ..rest_chars] -> parse_import_chars(rest_chars, string_builder.append(import_module, char)) } } /// Derives test modules from its src import dependencies /// fn derive_test_modules_from_src_import_dependencies( src_modules: List(String), ) -> List(String) { let project_test_files = find_project_files(in: "test") let all_test_modules = project_test_files |> list.map(fn(module_name_dot_gleam) { assert Ok(#(module_name, _dot_gleam)) = string.split_once(module_name_dot_gleam, ".gleam") module_name }) let dirty_test_modules = all_test_modules |> list.filter(fn(test_module) { let test_module_imports = derive_src_imports_off_test_module(test_module) list.any( in: src_modules, satisfying: fn(src_module) { test_module_imports |> list.contains(src_module) }, ) }) dirty_test_modules } /// Derives src imports from test module /// fn derive_src_imports_off_test_module(test_module_name) { test_module_name |> module_name_to_file_name(TestModuleKind) |> parse_module_for_imports } /// Converts a module name to a module file name /// fn module_name_to_file_name( module_name: String, module_kind: ModuleKind, ) -> String { case module_kind { SrcModuleKind -> get_src_dir() <> "/" <> module_name <> ".gleam" TestModuleKind -> get_test_dir() <> "/" <> module_name <> ".gleam" } } /// Converts a module file name to a module name /// fn file_name_to_module_name(module_name: String, module_kind: ModuleKind) { assert Ok(#(_base_path, module_name_dot_gleam)) = case module_kind { SrcModuleKind -> string.split_once(module_name, get_src_dir() <> "/") TestModuleKind -> string.split_once(module_name, get_test_dir() <> "/") } case string.ends_with(module_name, ".erl") { True -> { assert Ok(#(module_name, _dot_gleam)) = string.split_once(module_name_dot_gleam, ".erl") module_name } False -> { assert Ok(#(module_name, _dot_gleam)) = string.split_once(module_name_dot_gleam, ".gleam") module_name } } } /// Checks if a given absolute file path exists /// fn file_exists(absolute_file_name: String) -> Bool { do_file_exists(absolute_file_name) } /// Finds files in project sub directory /// fn find_project_files(in sub_directory: String) -> List(String) { do_find_project_files(sub_directory) } /// Get's the target at runtime /// fn target() -> Target { do_target() } /// Get's the start arguments sometimes called argv. /// fn start_args() -> List(String) { do_start_args() } /// Get's the current project directory. /// fn get_cwd() -> String { do_get_cwd() } /// Get's the project's src directory. /// fn get_src_dir() -> String { get_cwd() <> "/src" } /// Get's the project's test directory. /// fn get_test_dir() -> String { get_cwd() <> "/test" } /// Cut's off the base path from the project directory. /// fn to_relative_path(absolute_file_path path: String) -> String { assert Ok(#(_pre_path, relative_file_name)) = string.split_once(path, get_cwd() <> "/") relative_file_name } if erlang { import gleam/erlang import gleam/erlang/file fn do_target() -> Target { ErlangTarget } fn do_start_args() -> List(String) { erlang.start_arguments() } external fn do_start_file_change_watcher( file_change_handler: fn(List(#(ModuleKind, String))) -> Nil, ) -> Nil = "glacier_ffi" "start_file_change_watcher" fn read_module_file(module_path: String) -> String { assert Ok(contents) = file.read(module_path) contents } external fn do_get_cwd() -> String = "glacier_ffi" "get_cwd_as_binary" external fn do_file_exists(absolute_file_name: String) -> Bool = "filelib" "is_regular" fn do_find_project_files(in: String) -> List(String) { do_find_files_recursive(in: in, matching: "**/*.{gleam}") } external fn do_find_files_recursive( in: String, matching: String, ) -> List(String) = "glacier_ffi" "find_files_recursive" } if javascript { fn do_target() -> Target { JavaScriptTarget } external fn do_start_args() -> List(String) = "./glacier_ffi.mjs" "start_args" external fn do_start_file_change_watcher( file_change_handler: fn(List(#(ModuleKind, String))) -> Nil, ) -> Nil = "./glacier_ffi.mjs" "start_file_change_watcher" fn read_module_file(module_path: String) -> String { do_read_module_file(module_path) } external fn do_read_module_file(module_path: String) -> String = "./glacier_ffi.mjs" "read_file" external fn do_get_cwd() -> String = "./glacier_ffi.mjs" "cwd" external fn do_file_exists(absolute_file_name: String) -> Bool = "./glacier_ffi.mjs" "file_exists" fn do_find_project_files(dir: String) -> List(String) { do_find_files_recursive(dir, [".gleam"]) |> list.map(fn(file_name) { assert Ok(#(_test_prefix, file_name)) = string.split_once(file_name, "test/") file_name }) } external fn do_find_files_recursive( in: String, file_exists: List(String), ) -> List(String) = "./glacier_ffi.mjs" "find_files_recursive_by_exts" }