sources.ex (5597B)
1 defmodule Credo.Sources do 2 @moduledoc """ 3 This module is used to find and read all source files for analysis. 4 """ 5 6 alias Credo.Execution 7 alias Credo.SourceFile 8 9 @default_sources_glob ~w(** *.{ex,exs}) 10 @stdin_filename "stdin" 11 12 @doc """ 13 Finds sources for a given `Credo.Execution`. 14 15 Through the `files` key, configs may contain a list of `included` and `excluded` 16 patterns. For `included`, patterns can be file paths, directory paths and globs. 17 For `excluded`, patterns can also be specified as regular expressions. 18 19 iex> Sources.find(%Credo.Execution{files: %{excluded: ["not_me.ex"], included: ["*.ex"]}}) 20 21 iex> Sources.find(%Credo.Execution{files: %{excluded: [~r/messy/], included: ["lib/mix", "root.ex"]}}) 22 """ 23 def find(exec) 24 25 def find(%Execution{read_from_stdin: true, files: %{included: [filename]}}) do 26 filename 27 |> source_file_from_stdin() 28 |> List.wrap() 29 end 30 31 def find(%Execution{read_from_stdin: true}) do 32 @stdin_filename 33 |> source_file_from_stdin() 34 |> List.wrap() 35 end 36 37 def find(%Execution{files: files, parse_timeout: parse_timeout} = exec) do 38 parse_timeout = 39 if is_nil(parse_timeout) do 40 :infinity 41 else 42 parse_timeout 43 end 44 45 working_dir = Execution.working_dir(exec) 46 47 included_patterns = convert_globs_to_local_paths(working_dir, files.included) 48 excluded_patterns = convert_globs_to_local_paths(working_dir, files.excluded) 49 50 MapSet.new() 51 |> include(included_patterns) 52 |> exclude(excluded_patterns) 53 |> Enum.sort() 54 |> Enum.take(max_file_count()) 55 |> read_files(parse_timeout) 56 end 57 58 def find(nil) do 59 [] 60 end 61 62 def find(paths) when is_list(paths) do 63 Enum.flat_map(paths, &find/1) 64 end 65 66 def find(path) when is_binary(path) do 67 recurse_path(path) 68 end 69 70 @doc """ 71 Finds sources in a given `directory` using a list of `included` and `excluded` 72 patterns. For `included`, patterns can be file paths, directory paths and globs. 73 For `excluded`, patterns can also be specified as regular expressions. 74 75 iex> Sources.find_in_dir("/home/rrrene/elixir", ["*.ex"], ["not_me.ex"]) 76 77 iex> Sources.find_in_dir("/home/rrrene/elixir", ["*.ex"], [~r/messy/]) 78 """ 79 def find_in_dir(working_dir, included, excluded) 80 81 def find_in_dir(working_dir, [], excluded), 82 do: find_in_dir(working_dir, [Path.join(@default_sources_glob)], excluded) 83 84 def find_in_dir(working_dir, included, excluded) do 85 included_patterns = convert_globs_to_local_paths(working_dir, included) 86 excluded_patterns = convert_globs_to_local_paths(working_dir, excluded) 87 88 MapSet.new() 89 |> include(included_patterns) 90 |> exclude(excluded_patterns) 91 |> Enum.sort() 92 |> Enum.take(max_file_count()) 93 end 94 95 defp convert_globs_to_local_paths(working_dir, patterns) do 96 patterns 97 |> List.wrap() 98 |> Enum.map(fn 99 pattern when is_binary(pattern) -> Path.expand(pattern, working_dir) 100 pattern -> pattern 101 end) 102 end 103 104 defp max_file_count do 105 max_files = System.get_env("MAX_FILES") 106 107 if max_files do 108 String.to_integer(max_files) 109 else 110 1_000_000 111 end 112 end 113 114 defp include(files, []), do: files 115 116 defp include(files, [path | remaining_paths]) do 117 include_paths = 118 path 119 |> recurse_path() 120 |> Enum.into(MapSet.new()) 121 122 files 123 |> MapSet.union(include_paths) 124 |> include(remaining_paths) 125 end 126 127 defp exclude(files, []), do: files 128 129 defp exclude(files, [pattern | remaining_patterns]) when is_list(files) do 130 files 131 |> Enum.into(MapSet.new()) 132 |> exclude([pattern | remaining_patterns]) 133 end 134 135 defp exclude(files, [pattern | remaining_patterns]) when is_binary(pattern) do 136 exclude_paths = 137 pattern 138 |> recurse_path 139 |> Enum.into(MapSet.new()) 140 141 files 142 |> MapSet.difference(exclude_paths) 143 |> exclude(remaining_patterns) 144 end 145 146 defp exclude(files, [pattern | remaining_patterns]) do 147 files 148 |> Enum.reject(&String.match?(&1, pattern)) 149 |> exclude(remaining_patterns) 150 end 151 152 defp recurse_path(path) do 153 paths = 154 cond do 155 File.regular?(path) -> 156 [path] 157 158 File.dir?(path) -> 159 [path | @default_sources_glob] 160 |> Path.join() 161 |> Path.wildcard() 162 163 true -> 164 path 165 |> Path.wildcard() 166 |> Enum.flat_map(&recurse_path/1) 167 end 168 169 Enum.map(paths, &Path.expand/1) 170 end 171 172 defp read_files(filenames, parse_timeout) do 173 tasks = Enum.map(filenames, &Task.async(fn -> to_source_file(&1) end)) 174 175 task_dictionary = 176 tasks 177 |> Enum.zip(filenames) 178 |> Enum.into(%{}) 179 180 tasks_with_results = Task.yield_many(tasks, parse_timeout) 181 182 results = 183 Enum.map(tasks_with_results, fn {task, res} -> 184 # Shutdown the tasks that did not reply nor exit 185 {task, res || Task.shutdown(task, :brutal_kill)} 186 end) 187 188 Enum.map(results, fn 189 {_task, {:ok, value}} -> value 190 {task, nil} -> SourceFile.timed_out(task_dictionary[task]) 191 end) 192 end 193 194 defp to_source_file(filename) do 195 filename 196 |> File.read!() 197 |> SourceFile.parse(filename) 198 end 199 200 defp source_file_from_stdin(filename) do 201 SourceFile.parse(read_from_stdin!(), filename) 202 end 203 204 defp read_from_stdin! do 205 {:ok, source} = read_from_stdin() 206 source 207 end 208 209 defp read_from_stdin(source \\ "") do 210 case IO.read(:stdio, :line) do 211 {:error, reason} -> 212 {:error, reason} 213 214 :eof -> 215 {:ok, source} 216 217 data -> 218 source = source <> data 219 read_from_stdin(source) 220 end 221 end 222 end