Skip to content

Commit cdcdd20

Browse files
Allow reading multiple file solutions and exemploids (#298)
* General updates * Read multiple files * Removed suppressed compilation warning * Adapt tests to match lib changes * Add smoke tests with multiple files * Remove extra space * Add newline * Apply suggestions from code review Co-authored-by: Angelika Tyborska <[email protected]> * Replace ls_r by Path.wildcard * Fix error message * Renaming (_path to _files) * Start iterator with empty string * Revert "Start iterator with empty string" not to mess with line numbers This reverts commit b0b04ce. Co-authored-by: Angelika Tyborska <[email protected]>
1 parent 3697e5d commit cdcdd20

File tree

28 files changed

+361
-78
lines changed

28 files changed

+361
-78
lines changed

elixir

lib/elixir_analyzer.ex

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ defmodule ElixirAnalyzer do
7979

8080
defaults = [
8181
{:exercise, exercise},
82-
{:path, input_path},
82+
{:path, String.trim_leading(input_path, "./")},
8383
{:output_path, output_path},
8484
{:output_file, @output_file},
8585
{:exercise_config, default_exercise_config()},
@@ -107,14 +107,14 @@ defmodule ElixirAnalyzer do
107107
Logger.debug("Getting the exercise config")
108108
exercise_config = params.exercise_config[params.exercise]
109109

110-
{code_path, exercise_type, exemploid_path, analysis_module} =
110+
{submitted_files, exercise_type, exemploid_files, analysis_module} =
111111
do_init(params, exercise_config)
112112

113113
Logger.debug("Initialization successful",
114114
path: params.path,
115-
code_path: code_path,
115+
submitted_files: submitted_files,
116116
exercise_type: exercise_type,
117-
exemploid_path: exemploid_path,
117+
exemploid_files: exemploid_files,
118118
analysis_module: analysis_module
119119
)
120120

@@ -129,9 +129,9 @@ defmodule ElixirAnalyzer do
129129

130130
source = %{
131131
source
132-
| code_path: code_path,
132+
| submitted_files: submitted_files,
133133
exercise_type: exercise_type,
134-
exemploid_path: exemploid_path
134+
exemploid_files: exemploid_files
135135
}
136136

137137
%{
@@ -165,16 +165,28 @@ defmodule ElixirAnalyzer do
165165

166166
defp do_init(params, exercise_config) do
167167
meta_config = Path.join(params.path, @meta_config) |> File.read!() |> Jason.decode!()
168-
relative_code_path = meta_config["files"]["solution"] |> hd()
169-
code_path = Path.join(params.path, relative_code_path)
168+
solution_files = meta_config["files"]["solution"] |> Enum.map(&Path.join(params.path, &1))
169+
if Enum.empty?(solution_files), do: raise("No solution files specified")
170170

171-
{exercise_type, exemploid_path} =
171+
submitted_files =
172+
Path.join([params.path, "lib", "**", "*.ex"])
173+
|> Path.wildcard()
174+
|> Enum.concat(solution_files)
175+
|> Enum.uniq()
176+
|> Enum.sort()
177+
178+
editor_files = Map.get(meta_config["files"], "editor", [])
179+
180+
{exercise_type, exemploid_files} =
172181
case meta_config["files"] do
173-
%{"exemplar" => [path | _]} -> {:concept, Path.join(params.path, path)}
174-
%{"example" => [path | _]} -> {:practice, Path.join(params.path, path)}
182+
%{"exemplar" => path} -> {:concept, path}
183+
%{"example" => path} -> {:practice, path}
175184
end
176185

177-
{code_path, exercise_type, exemploid_path,
186+
exemploid_files =
187+
(editor_files ++ exemploid_files) |> Enum.sort() |> Enum.map(&Path.join(params.path, &1))
188+
189+
{submitted_files, exercise_type, exemploid_files,
178190
exercise_config[:analyzer_module] || ElixirAnalyzer.TestSuite.Default}
179191
end
180192

@@ -190,15 +202,15 @@ defmodule ElixirAnalyzer do
190202
end
191203

192204
defp check(%Submission{source: source} = submission, _params) do
193-
Logger.info("Attempting to read code file", code_file_path: source.code_path)
205+
Logger.info("Attempting to read code files", code_file_path: source.submitted_files)
194206

195-
with {:code_read, {:ok, code_string}} <- {:code_read, File.read(source.code_path)},
207+
with {:code_read, {:ok, code_string}} <- {:code_read, read_files(source.submitted_files)},
196208
source <- %{source | code_string: code_string},
197-
Logger.info("Code file read successfully"),
198-
Logger.info("Attempting to read exemploid", exemploid_path: source.exemploid_path),
209+
Logger.info("Code files read successfully"),
210+
Logger.info("Attempting to read exemploid", exemploid_files: source.exemploid_files),
199211
{:exemploid_read, _, {:ok, exemploid_string}} <-
200-
{:exemploid_read, source, File.read(source.exemploid_path)},
201-
Logger.info("Exemploid file read successfully, attempting to parse"),
212+
{:exemploid_read, source, read_files(source.exemploid_files)},
213+
Logger.info("Exemploid files read successfully, attempting to parse"),
202214
{:exemploid_ast, _, {:ok, exemploid_ast}} <-
203215
{:exemploid_ast, source, Code.string_to_quoted(exemploid_string)} do
204216
Logger.info("Exemploid file parsed successfully")
@@ -208,36 +220,50 @@ defmodule ElixirAnalyzer do
208220
{:code_read, {:error, reason}} ->
209221
Logger.warning("TestSuite halted: Code file not found. Reason: #{reason}",
210222
path: source.path,
211-
code_path: source.code_path
223+
submitted_files: source.submitted_files
212224
)
213225

214226
submission
215227
|> Submission.halt()
216228
|> Submission.append_comment(%Comment{
217229
comment: Constants.general_file_not_found(),
218230
params: %{
219-
"file_name" => Path.basename(source.code_path),
231+
"file_name" => Path.basename(source.submitted_files),
220232
"path" => source.path
221233
},
222234
type: :essential
223235
})
224236

225237
{:exemploid_read, source, {:error, reason}} ->
226238
Logger.warning("Exemploid file not found. Reason: #{reason}",
227-
exemploid_path: source.exemploid_path
239+
exemploid_files: source.exemploid_files
228240
)
229241

230242
%{submission | source: source}
231243

232244
{:exemploid_ast, source, {:error, reason}} ->
233245
Logger.warning("Exemploid file could not be parsed. Reason: #{inspect(reason)}",
234-
exemploid_path: source.exemploid_path
246+
exemploid_files: source.exemploid_files
235247
)
236248

237249
%{submission | source: source}
238250
end
239251
end
240252

253+
defp read_files(paths) do
254+
Enum.reduce_while(
255+
paths,
256+
{:ok, nil},
257+
fn path, {:ok, code} ->
258+
case File.read(path) do
259+
{:ok, file} when is_nil(code) -> {:cont, {:ok, file}}
260+
{:ok, file} -> {:cont, {:ok, code <> "\n" <> file}}
261+
{:error, err} -> {:halt, {:error, err}}
262+
end
263+
end
264+
)
265+
end
266+
241267
# Analyze
242268
# - Start the static analysis
243269
defp analyze(%Submission{halted: true} = submission, _params) do

lib/elixir_analyzer/exercise_test/common_checks.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ defmodule ElixirAnalyzer.ExerciseTest.CommonChecks do
3535

3636
@spec run(Source.t()) :: [{:pass | :fail, Comment.t()}]
3737
def run(%Source{
38-
code_path: code_path,
38+
submitted_files: submitted_files,
3939
code_ast: code_ast,
4040
code_string: code_string,
4141
exercise_type: type,
@@ -46,7 +46,7 @@ defmodule ElixirAnalyzer.ExerciseTest.CommonChecks do
4646
VariableNames.run(code_ast),
4747
ModuleAttributeNames.run(code_ast),
4848
ModulePascalCase.run(code_ast),
49-
CompilerWarnings.run(code_path, code_ast),
49+
CompilerWarnings.run(submitted_files),
5050
BooleanFunctions.run(code_ast),
5151
FunctionAnnotationOrder.run(code_ast),
5252
ExemplarComparison.run(code_ast, type, exemploid_ast),

lib/elixir_analyzer/exercise_test/common_checks/compiler_warnings.ex

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,30 @@ defmodule ElixirAnalyzer.ExerciseTest.CommonChecks.CompilerWarnings do
55
alias ElixirAnalyzer.Constants
66
alias ElixirAnalyzer.Comment
77

8-
def run(code_path, _code_ast) do
9-
import ExUnit.CaptureIO
10-
Application.put_env(:elixir, :ansi_enabled, false)
8+
def run(code_path) do
9+
Logger.configure(level: :critical)
1110

1211
warnings =
13-
capture_io(:stderr, fn ->
14-
try do
15-
Code.compile_file(code_path)
16-
|> Enum.each(fn {module, _binary} ->
12+
case Kernel.ParallelCompiler.compile(code_path) do
13+
{:ok, modules, warnings} ->
14+
Enum.each(modules, fn module ->
1715
:code.delete(module)
1816
:code.purge(module)
1917
end)
20-
rescue
21-
# There are too many compile errors for tests, so we filter them out
22-
# We assume that real code passed the tests and therefore compiles
23-
_ -> nil
24-
end
25-
end)
18+
19+
warnings
20+
21+
{:error, _errors, _warnings} ->
22+
# This should not happen, as real code is assumed to have compiled and
23+
# passed the tests
24+
[]
25+
end
26+
27+
Logger.configure(level: :warn)
2628

2729
Application.put_env(:elixir, :ansi_enabled, true)
2830

29-
if warnings == "" do
31+
if Enum.empty?(warnings) do
3032
[]
3133
else
3234
[
@@ -35,9 +37,20 @@ defmodule ElixirAnalyzer.ExerciseTest.CommonChecks.CompilerWarnings do
3537
type: :actionable,
3638
name: Constants.solution_compiler_warnings(),
3739
comment: Constants.solution_compiler_warnings(),
38-
params: %{warnings: warnings}
40+
params: %{warnings: Enum.map_join(warnings, &format_warning/1)}
3941
}}
4042
]
4143
end
4244
end
45+
46+
defp format_warning({filepath, line, warning}) do
47+
[_ | after_lib] = String.split(filepath, "/lib/")
48+
filepath = "lib/" <> Enum.join(after_lib)
49+
50+
"""
51+
warning: #{warning}
52+
#{filepath}:#{line}
53+
54+
"""
55+
end
4356
end

lib/elixir_analyzer/source.ex

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,23 @@ defmodule ElixirAnalyzer.Source do
55
defstruct [
66
:slug,
77
:path,
8-
:code_path,
8+
:submitted_files,
99
:code_string,
1010
:code_ast,
1111
:exercise_type,
12-
:exemploid_path,
12+
:exemploid_files,
1313
:exemploid_string,
1414
:exemploid_ast
1515
]
1616

1717
@type t() :: %__MODULE__{
1818
slug: String.t(),
1919
path: String.t(),
20-
code_path: String.t(),
20+
submitted_files: [String.t()],
2121
code_string: String.t(),
2222
code_ast: Macro.t(),
2323
exercise_type: :concept | :practice,
24-
exemploid_path: String.t(),
24+
exemploid_files: [String.t()],
2525
exemploid_string: String.t(),
2626
exemploid_ast: Macro.t()
2727
}

lib/elixir_analyzer/test_suite/take_a_number_deluxe.ex

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@ defmodule ElixirAnalyzer.TestSuite.TakeANumberDeluxe do
66
alias ElixirAnalyzer.Constants
77
alias ElixirAnalyzer.Source
88

9-
use ElixirAnalyzer.ExerciseTest,
10-
# this is temporary until we include editor files in compilation
11-
suppress_tests: [Constants.solution_compiler_warnings()]
9+
use ElixirAnalyzer.ExerciseTest
1210

1311
feature "uses GenServer" do
1412
type :actionable

test/elixir_analyzer/cli_test.exs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@ defmodule ElixirAnalyzer.CLITest do
2929
source: %Source{
3030
code_ast: {:defmodule, _, _},
3131
code_string: "defmodule Lasagna" <> _,
32-
code_path: @lasagna_path <> "/lib/lasagna.ex",
32+
submitted_files: [@lasagna_path <> "/lib/lasagna.ex"],
3333
exemploid_ast: {:defmodule, _, _},
3434
exemploid_string: "defmodule Lasagna" <> _,
35-
exemploid_path: @lasagna_path <> "/.meta/exemplar.ex",
35+
exemploid_files: [@lasagna_path <> "/.meta/exemplar.ex"],
3636
exercise_type: :concept,
3737
path: @lasagna_path,
3838
slug: "lasagna"

test/elixir_analyzer/exercise_test/common_checks/compiler_warnings_test.exs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ defmodule ElixirAnalyzer.ExerciseTest.CommonChecks.CompilerWarningsTest do
33
alias ElixirAnalyzer.ExerciseTest.CommonChecks.CompilerWarnings
44

55
test "Implementing a protocol doesn't trigger a compiler warning" do
6-
filepath = "test_data/clock/lib/clock.ex"
7-
assert CompilerWarnings.run(filepath, nil) == []
6+
filepath = "test_data/clock/perfect_solution/lib/clock.ex"
7+
assert CompilerWarnings.run([filepath]) == []
88
end
99
end

0 commit comments

Comments
 (0)