Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New common check: Helper functions should be private #207

Merged
merged 21 commits into from
Nov 6, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .github/workflows/elixir_test_external.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,25 @@ jobs:
image: hexpm/elixir:1.12.1-erlang-24.0.1-ubuntu-focal-20210325

steps:
- uses: actions/checkout@v1
- name: Install git
run: |
apt-get update
apt-get install -y git

- name: Checkout repository and submodules
uses: actions/checkout@v2
with:
submodules: recursive

- name: Update submodules
run: |
git submodule update --recursive --remote

- name: Install Dependencies
run: |
mix local.rebar --force
mix local.hex --force
mix deps.get

- name: Run Tests
run: mix test --only external
2 changes: 1 addition & 1 deletion elixir
Submodule elixir updated 92 files
+1 −1 .github/pr-commenter.yml
+2 −2 .github/workflows/ci.yml
+1 −1 .github/workflows/configlet.yml
+2 −2 .github/workflows/pr.ci.yml
+1 −1 .github/workflows/sync-labels.yml
+3 −0 CONTRIBUTING.md
+7 −0 concepts/access-behaviour/about.md
+1 −1 concepts/access-behaviour/introduction.md
+1 −1 concepts/basics/about.md
+1 −1 concepts/binaries/introduction.md
+1 −1 concepts/file/about.md
+1 −1 concepts/file/introduction.md
+2 −1 concepts/if/about.md
+1 −1 concepts/if/introduction.md
+1 −1 concepts/lists/introduction.md
+1 −1 concepts/pattern-matching/links.json
+9 −0 concepts/with/.meta/config.json
+65 −0 concepts/with/about.md
+24 −0 concepts/with/introduction.md
+14 −0 concepts/with/links.json
+31 −7 config.json
+1 −1 exercises/concept/basketball-website/.docs/introduction.md
+0 −1 exercises/concept/basketball-website/.meta/exemplar.ex
+2 −1 exercises/concept/boutique-suggestions/.meta/config.json
+5 −0 exercises/concept/boutique-suggestions/test/boutique_suggestions_test.exs
+3 −1 exercises/concept/community-garden/.meta/config.json
+32 −0 exercises/concept/community-garden/test/community_garden_test.exs
+4 −2 exercises/concept/dna-encoding/.meta/exemplar.ex
+1 −1 exercises/concept/file-sniffer/.docs/introduction.md
+1 −2 exercises/concept/high-score/test/high_score_test.exs
+1 −1 exercises/concept/language-list/.docs/introduction.md
+5 −5 exercises/concept/lasagna/lib/lasagna.ex
+2 −2 exercises/concept/lucas-numbers/.docs/hints.md
+1 −1 exercises/concept/name-badge/.docs/introduction.md
+40 −0 exercises/concept/new-passport/.docs/hints.md
+35 −0 exercises/concept/new-passport/.docs/instructions.md
+24 −0 exercises/concept/new-passport/.docs/introduction.md
+4 −0 exercises/concept/new-passport/.formatter.exs
+24 −0 exercises/concept/new-passport/.gitignore
+17 −0 exercises/concept/new-passport/.meta/config.json
+36 −0 exercises/concept/new-passport/.meta/design.md
+63 −0 exercises/concept/new-passport/.meta/exemplar.ex
+52 −0 exercises/concept/new-passport/lib/new_passport.ex
+28 −0 exercises/concept/new-passport/mix.exs
+155 −0 exercises/concept/new-passport/test/new_passport_test.exs
+2 −0 exercises/concept/new-passport/test/test_helper.exs
+1 −1 exercises/concept/newsletter/.docs/introduction.md
+1 −1 exercises/concept/newsletter/test/newsletter_test.exs
+1 −1 exercises/concept/remote-control-car/.docs/instructions.md
+1 −1 exercises/concept/stack-underflow/.docs/instructions.md
+4 −4 exercises/practice/affine-cipher/.meta/example.ex
+4 −0 exercises/practice/bank-account/.meta/example.ex
+2 −2 exercises/practice/binary-search-tree/.meta/example.ex
+8 −8 exercises/practice/book-store/.meta/example.ex
+10 −0 exercises/practice/book-store/.meta/tests.toml
+24 −0 exercises/practice/book-store/test/book_store_test.exs
+11 −11 exercises/practice/circular-buffer/.meta/example.ex
+19 −10 exercises/practice/complex-numbers/.meta/example.ex
+64 −33 exercises/practice/complex-numbers/.meta/tests.toml
+8 −8 exercises/practice/complex-numbers/lib/complex_numbers.ex
+82 −0 exercises/practice/complex-numbers/test/complex_numbers_test.exs
+2 −2 exercises/practice/dominoes/.meta/example.ex
+1 −1 exercises/practice/dominoes/lib/dominoes.ex
+3 −3 exercises/practice/food-chain/.meta/example.ex
+20 −23 exercises/practice/forth/.meta/example.ex
+10 −10 exercises/practice/go-counting/.meta/example.ex
+1 −1 exercises/practice/house/.meta/example.ex
+7 −0 exercises/practice/luhn/.meta/tests.toml
+10 −0 exercises/practice/luhn/test/luhn_test.exs
+26 −25 exercises/practice/ocr-numbers/.meta/example.ex
+3 −3 exercises/practice/parallel-letter-frequency/.meta/example.ex
+21 −49 exercises/practice/pov/.meta/example.ex
+5 −7 exercises/practice/pythagorean-triplet/.meta/example.ex
+51 −41 exercises/practice/rational-numbers/.meta/tests.toml
+7 −7 exercises/practice/rational-numbers/test/rational_numbers_test.exs
+3 −3 exercises/practice/rectangles/.meta/example.ex
+7 −0 exercises/practice/resistor-color-duo/.meta/tests.toml
+18 −0 exercises/practice/resistor-color-duo/test/resistor_color_duo_test.exs
+3 −3 exercises/practice/rotational-cipher/.meta/example.ex
+4 −4 exercises/practice/satellite/.meta/example.ex
+22 −33 exercises/practice/sgf-parsing/.meta/example.ex
+1 −0 exercises/practice/sieve/.meta/config.json
+9 −3 exercises/practice/sieve/.meta/example.ex
+2 −2 exercises/practice/simple-linked-list/.meta/example.ex
+1 −1 exercises/practice/square-root/.meta/example.ex
+13 −3 exercises/practice/sublist/.meta/tests.toml
+5 −0 exercises/practice/sublist/test/sublist_test.exs
+4 −4 exercises/practice/two-bucket/.meta/example.ex
+15 −13 exercises/practice/variable-length-quantity/.meta/example.ex
+3 −3 exercises/practice/word-search/.meta/example.ex
+8 −8 exercises/practice/zebra-puzzle/.meta/example.ex
+14 −1 exercises/practice/zipper/lib/bin_tree.ex
127 changes: 58 additions & 69 deletions lib/elixir_analyzer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ defmodule ElixirAnalyzer do
alias ElixirAnalyzer.Constants
alias ElixirAnalyzer.Submission
alias ElixirAnalyzer.Comment
alias ElixirAnalyzer.Source

import ElixirAnalyzer.Summary, only: [summary: 2]

Expand Down Expand Up @@ -85,8 +86,6 @@ defmodule ElixirAnalyzer do
defaults = [
{:exercise, exercise},
{:path, input_path},
{:file, nil},
{:module, nil},
{:output_path, output_path},
{:output_file, @output_file},
{:exercise_config, default_exercise_config()},
Expand All @@ -100,22 +99,28 @@ defmodule ElixirAnalyzer do
# Do init work
# -read config, create the initial Submission struct
defp init(params) do
submission = %Submission{
source = %Source{
path: params.path,
code_path: nil,
code_file: nil,
slug: params.exercise
}

submission = %Submission{
source: source,
analysis_module: nil
}

try do
Logger.debug("Getting the exercise config")
exercise_config = params.exercise_config[params.exercise]
{code_path, code_file, exemplar_path, analysis_module} = do_init(params, exercise_config)

{code_path, exercise_type, exemploid_path, analysis_module} =
do_init(params, exercise_config)

Logger.debug("Initialization successful",
path: params.path,
code_path: code_path,
exemplar_path: exemplar_path,
exercise_type: exercise_type,
exemploid_path: exemploid_path,
analysis_module: analysis_module
)

Expand All @@ -128,11 +133,16 @@ defmodule ElixirAnalyzer do
Logger.info("Exercise test suite '#{m}' found and loaded.")
end

source = %{
source
| code_path: code_path,
exercise_type: exercise_type,
exemploid_path: exemploid_path
}

%{
submission
| code_path: code_path,
code_file: code_file,
exemplar_path: exemplar_path,
| source: source,
analysis_module: analysis_module
}
rescue
Expand All @@ -159,99 +169,78 @@ defmodule ElixirAnalyzer do
end
end

# When file is nil, pull code params from config file
defp do_init(%{file: nil} = params, exercise_config) do
defp do_init(params, exercise_config) do
meta_config = Path.join(params.path, @meta_config) |> File.read!() |> Jason.decode!()
relative_code_path = meta_config["files"]["solution"] |> hd()
full_code_path = Path.join(params.path, relative_code_path)
code_path = Path.join(params.path, relative_code_path)

code_path = Path.dirname(full_code_path)
code_file = Path.basename(full_code_path)

exemplar_path =
case meta_config["files"]["exemplar"] do
[path | _] -> Path.join(params.path, path)
_ -> nil
{exercise_type, exemploid_path} =
case meta_config["files"] do
%{"exemplar" => [path | _]} -> {:concept, Path.join(params.path, path)}
%{"example" => [path | _]} -> {:practice, Path.join(params.path, path)}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder about these:

relative_code_path = meta_config["files"]["solution"] |> hd()
[...]
%{"exemplar" => [path | _]} -> {:concept, Path.join(params.path, path)}
%{"example" => [path | _]} -> {:practice, Path.join(params.path, path)}

After a quick check, it looks like all solution/example/exemplar values in all config.json only have one file in there. But that doesn't mean that we are capturing the full code. There are some exercises with the editor field, and students that submit solutions via the CLI may add more files.

I'll open an issue after this PR gets merged so we can form a plan.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@neenjaw do I remember correctly that your initial assumption was to just ignore multi file submissions? I think that was totally reasonable as they are very rare. It would be cool to at least get the basic common checks working for multi file solutions (e.g. proper name casing, indentation) but I wouldn't spend too much effort on making it perfect.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I wrote that line of code, example files were only a single file. If that's not the case anymore, then makes sense to change strategy.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Example files are still a single file, but the checks only look at the file mentioned in meta_config["files"]["solution"]. In case students submit several files, they are not looked at. And might actually get a compiler error as an analyzer comment, now that I think about it. Could you re-submit one of your multiple file solutions and check?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compiled elixir is still dynamically linked at run time so it just needs to be legal syntax, not runnable code, for code to compile successfully.

iex(1)> Code.compile_string("""
...(1)> defmodule MyModule do
...(1)>   def foo do
...(1)>     SomeNonExistentModule.bar(2)
...(1)>   end
...(1)> end
...(1)> """)
[
  {MyModule,
   <<70, 79, 82, 49, 0, 0, 4, 236, 66, 69, 65, 77, 65, 116, 85, 56, 0, 0, 0,
     171, 0, 0, 0, 16, 15, 69, 108, 105, 120, 105, 114, 46, 77, 121, 77, 111,
     100, 117, 108, 101, 8, 95, 95, 105, 110, 102, 111, ...>>}
]

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's true enough, although not foolproof.

iex(42)> Code.compile_string("""
...(42)> defmodule MyModule do
...(42)> import SomeNonExistentModule    
...(42)> def foo do
...(42)>   bar(2)
...(42)> end
...(42)> end
...(42)> """ )
** (CompileError) nofile:2: module SomeNonExistentModule is not loaded and could not be found

end

{code_path, code_file, exemplar_path,
{code_path, exercise_type, exemploid_path,
exercise_config[:analyzer_module] || ElixirAnalyzer.TestSuite.Default}
end

# Else, use passed in params to analyze
defp do_init(params, _exercise_config) do
{
params.path,
params.file,
String.to_existing_atom("ElixirAnalyzer.ExerciseTest.#{params.module}")
}
end

# Check
# - check if the file exists
# - read in the code
# - check if there is an exemplar
# - read in the exemplar
# - parse the exemplar into an AST
# - check if there is an exemploid
# - read in the exemploid
# - parse the exemploid into an AST
defp check(%Submission{halted: true} = submission, _params) do
Logger.warning("Check not performed, halted previously")
submission
end

defp check(%Submission{} = submission, _params) do
with path_to_code <- Path.join(submission.code_path, submission.code_file),
:ok <- Logger.info("Attempting to read code file", code_file_path: path_to_code),
{:code_read, {:ok, code_str}} <- {:code_read, File.read(path_to_code)},
:ok <- Logger.info("Code file read successfully"),
submission <- %{submission | code: code_str},
:ok <- Logger.info("Check if exemplar exists", exemplar_path: submission.exemplar_path),
{:exemplar_exists, submission, exemplar_path} when not is_nil(exemplar_path) <-
{:exemplar_exists, submission, submission.exemplar_path},
:ok <-
Logger.info("Exemplar file exists, attempting to read", exemplar_path: exemplar_path),
{:exemplar_read, submission, {:ok, exemplar_code}} <-
{:exemplar_read, submission, File.read(exemplar_path)},
:ok <- Logger.info("Exemplar file read successfully, attempting to parse"),
{:exemplar_ast, submission, {:ok, exemplar_code}} <-
{:exemplar_ast, submission, Code.string_to_quoted(exemplar_code)},
:ok <- Logger.info("Exemplar file parsed successfully"),
submission <- %{submission | exemplar_code: exemplar_code} do
submission
defp check(%Submission{source: source} = submission, _params) do
Logger.info("Attempting to read code file", code_file_path: source.code_path)

with {:code_read, {:ok, code_string}} <- {:code_read, File.read(source.code_path)},
source <- %{source | code_string: code_string},
Logger.info("Code file read successfully"),
Logger.info("Attempting to read exemploid", exemploid_path: source.exemploid_path),
{:exemploid_read, _, {:ok, exemploid_string}} <-
{:exemploid_read, source, File.read(source.exemploid_path)},
Logger.info("Exemploid file read successfully, attempting to parse"),
{:exemploid_ast, _, {:ok, exemploid_ast}} <-
{:exemploid_ast, source, Code.string_to_quoted(exemploid_string)} do
Logger.info("Exemploid file parsed successfully")
source = %{source | exemploid_string: exemploid_string, exemploid_ast: exemploid_ast}
%{submission | source: source}
else
{:code_read, {:error, reason}} ->
Logger.warning("TestSuite halted: Code file not found. Reason: #{reason}",
path: submission.path,
file_name: submission.code_file
path: source.path,
code_path: source.code_path
)

submission
|> Submission.halt()
|> Submission.append_comment(%Comment{
comment: Constants.general_file_not_found(),
params: %{
"file_name" => submission.code_file,
"path" => submission.path
"file_name" => Path.basename(source.code_path),
"path" => source.path
},
type: :essential
})

{:exemplar_exists, submission, nil} ->
jiegillet marked this conversation as resolved.
Show resolved Hide resolved
Logger.info("There is no exemplar file for this exercise")
submission

{:exemplar_read, submission, {:error, reason}} ->
Logger.warning("Exemplar file not found. Reason: #{reason}",
exemplar_path: submission.exemplar_path
{:exemploid_read, source, {:error, reason}} ->
Logger.warning("Exemploid file not found. Reason: #{reason}",
exemploid_path: source.exemploid_path
)

submission
%{submission | source: source}

{:exemplar_ast, submission, {:error, reason}} ->
Logger.warning("Exemplar file could not be parsed. Reason: #{inspect(reason)}",
exemplar_code: submission.exemplar_code
{:exemploid_ast, source, {:error, reason}} ->
Logger.warning("Exemploid file could not be parsed. Reason: #{inspect(reason)}",
exemploid_path: source.exemploid_path
)

submission
%{submission | source: source}
end
end

Expand All @@ -267,7 +256,7 @@ defmodule ElixirAnalyzer do

submission =
submission
|> submission.analysis_module.analyze(submission.code, submission.exemplar_code)
|> submission.analysis_module.analyze(submission.source)
|> Submission.set_analyzed(true)

Logger.info("Analyzing code complete")
Expand Down
1 change: 1 addition & 0 deletions lib/elixir_analyzer/constants.ex
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ defmodule ElixirAnalyzer.Constants do
solution_no_integer_literal: "elixir.solution.no_integer_literal",
solution_boilerplate_comment: "elixir.solution.boilerplate_comment",
solution_todo_comment: "elixir.solution.todo_comment",
solution_private_helper_functions: "elixir.solution.private_helper_functions",

# Concept exercises

Expand Down
27 changes: 17 additions & 10 deletions lib/elixir_analyzer/exercise_test.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ defmodule ElixirAnalyzer.ExerciseTest do
alias ElixirAnalyzer.Submission
alias ElixirAnalyzer.Constants
alias ElixirAnalyzer.Comment
alias ElixirAnalyzer.Source

@doc false
defmacro __using__(opts) do
Expand All @@ -21,7 +22,7 @@ defmodule ElixirAnalyzer.ExerciseTest do

import unquote(__MODULE__)
@before_compile unquote(__MODULE__)
@dialyzer no_match: {:do_analyze, 4}
@dialyzer no_match: {:do_analyze, 2}
end
end

Expand All @@ -38,7 +39,7 @@ defmodule ElixirAnalyzer.ExerciseTest do

# placeholders for submission code
code_ast = quote do: code_ast
code_as_string = quote do: code_as_string
code_string = quote do: code_string

# compile each feature to a test
feature_tests = Enum.map(feature_test_data, &FeatureCompiler.compile(&1, code_ast))
Expand All @@ -48,28 +49,34 @@ defmodule ElixirAnalyzer.ExerciseTest do

# compile each check_source to a test
check_source_tests =
Enum.map(check_source_data, &CheckSourceCompiler.compile(&1, code_as_string))
Enum.map(check_source_data, &CheckSourceCompiler.compile(&1, code_string))

quote do
@spec analyze(Submission.t(), String.t(), nil | Macro.t()) :: Submission.t()
def analyze(%Submission{} = submission, code_as_string, exemplar_ast) do
case Code.string_to_quoted(code_as_string) do
@spec analyze(Submission.t(), Source.t()) :: Submission.t()
def analyze(%Submission{} = submission, %Source{code_string: code_string} = source) do
case Code.string_to_quoted(code_string) do
{:ok, code_ast} ->
do_analyze(submission, code_ast, code_as_string, exemplar_ast)
source = %{source | code_ast: code_ast}
do_analyze(submission, source)

{:error, e} ->
append_analysis_failure(submission, e)
end
end

defp do_analyze(%Submission{} = submission, code_ast, code_as_string, exemplar_ast)
when is_binary(code_as_string) do
defp do_analyze(
%Submission{} = submission,
%Source{
code_string: code_string,
code_ast: code_ast
} = source
) do
results =
Enum.concat([
unquote(feature_tests),
unquote(assert_call_tests),
unquote(check_source_tests),
CommonChecks.run(code_ast, code_as_string, exemplar_ast)
CommonChecks.run(source)
])
|> filter_suppressed_results()

Expand Down
21 changes: 15 additions & 6 deletions lib/elixir_analyzer/exercise_test/common_checks.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ defmodule ElixirAnalyzer.ExerciseTest.CommonChecks do
BooleanFunctions,
ExemplarComparison,
Indentation,
PrivateHelperFunctions,
Comments
}

alias ElixirAnalyzer.Comment
alias ElixirAnalyzer.Source

# CommonChecks that use feature or assert_call should be called here
defmacro __using__(_opts) do
Expand All @@ -27,18 +29,25 @@ defmodule ElixirAnalyzer.ExerciseTest.CommonChecks do
end
end

@spec run(Macro.t(), String.t(), nil | Macro.t()) :: [{:pass | :fail | :skip, %Comment{}}]
def run(code_ast, code_as_string, exemplar_ast) when is_binary(code_as_string) do
@spec run(Source.t()) :: [{:pass | :fail | :skip, %Comment{}}]
def run(%Source{
code_path: code_path,
code_ast: code_ast,
code_string: code_string,
exercise_type: type,
exemploid_ast: exemploid_ast
}) do
[
FunctionNames.run(code_ast),
VariableNames.run(code_ast),
ModuleAttributeNames.run(code_ast),
ModulePascalCase.run(code_ast),
CompilerWarnings.run(code_ast),
CompilerWarnings.run(code_path, code_ast),
BooleanFunctions.run(code_ast),
ExemplarComparison.run(code_ast, exemplar_ast),
Indentation.run(code_ast, code_as_string),
Comments.run(code_ast, code_as_string)
ExemplarComparison.run(code_ast, type, exemploid_ast),
Indentation.run(code_ast, code_string),
PrivateHelperFunctions.run(code_ast, exemploid_ast),
Comments.run(code_ast, code_string)
]
|> List.flatten()
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ defmodule ElixirAnalyzer.ExerciseTest.CommonChecks.CompilerWarnings do
alias ElixirAnalyzer.Constants
alias ElixirAnalyzer.Comment

def run(code_ast) do
def run(code_path, code_ast) do
import ExUnit.CaptureIO
Application.put_env(:elixir, :ansi_enabled, false)

warnings =
capture_io(:stderr, fn ->
try do
Code.compile_quoted(code_ast)
Code.compile_quoted(code_ast, Path.basename(code_path))
jiegillet marked this conversation as resolved.
Show resolved Hide resolved
|> Enum.each(fn {module, _binary} ->
:code.delete(module)
:code.purge(module)
Expand Down
Loading