Repository: parroty/excoveralls (elixir)

Issue:
Fail coverage if any file contains an unmatched coveralls-ignore-start
We get a lot of mileage out of https://github.com/parroty/excoveralls/pull/183 when we want to be more precise about the lines we're ignoring. Thanks @mruoss.

Nonetheless, we just noticed that if we don't properly terminate a `# coveralls-ignore-start`, excoveralls will proceed to ignore the remainder of the file, which could hide a coverage problem below the point where we intended to stop the ignore.

In our case, we had some cases where we erroneously used `coveralls-ignore-end` instead of `stop`, and it has taken us quite a while to notice the mistake.

I don't know if/when I'll be able to make time to try to PR this, but I wanted to at least come here first to see if there was anything missing from my understanding, or if there was already discussion along these lines.

Thanks

Pull request description:
Detect and warn about incorrectly used ignore-comments
This PR addresses the issue of [accidentally ignoring too much code coverage](https://github.com/parroty/excoveralls/issues/197). The issue happens when you add `coveralls-ignore-start` but forget to add the corresponding `-ignore-stop`. With this change, excoveralls is going to discard the un-closed ignore-block (i.e. not ignore the coverage) and give you a warning, so that you can more easily spot the mistake.

Besides the main goal, this change also adds a few other useful warnings to let you know about redundant or invalid ignore-comments, which I [previously outlined](https://github.com/parroty/excoveralls/issues/197#issuecomment-1979244971). This is an optional part, and we could remove it or split it into a separate PR if that would be more appropriate.

The most interesting part for review is `ignore.ex` and `ignore_test.exs`.

Here's what the warnings look like in the console output
![image](https://github.com/parroty/excoveralls/assets/205906/356371d8-403d-4695-b095-0907a1095494)

~~The PR is unfinished, it's missing some of the final touches: some tests, changelog entry, version bump, readme update. But it's feature-complete and so __I would love to get early feedback__, in case I need to change anything before I polish it. I'm looking forward to your thoughts and suggestions.~~

Resolves #197.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a5b0557..14d4134 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,9 @@
+Unreleased
+------
+#### Enhancements
+- Print warnings about incorrectly used ignore-markers (#325), such as start-marker
+without a corresponding stop-marker, or two start-markers without a stop-marker in-between etc.
+
 0.18.1
 ------
 #### Changes
diff --git a/lib/excoveralls/circle.ex b/lib/excoveralls/circle.ex
index 627f6dc..6c4bffa 100644
--- a/lib/excoveralls/circle.ex
+++ b/lib/excoveralls/circle.ex
@@ -3,6 +3,7 @@ defmodule ExCoveralls.Circle do
   Handles circle-ci integration with coveralls.
   """
   alias ExCoveralls.Poster
+  alias ExCoveralls.Stats
 
   def execute(stats, options) do
     json = generate_json(stats, Enum.into(options, %{}))
@@ -20,7 +21,7 @@ defmodule ExCoveralls.Circle do
       service_number: get_number(),
       service_job_id: get_job_id(),
       service_pull_request: get_pull_request(),
-      source_files: stats,
+      source_files: Stats.serialize(stats),
       git: generate_git_info(),
       parallel: options[:parallel],
       flag_name: options[:flagname]
diff --git a/lib/excoveralls/drone.ex b/lib/excoveralls/drone.ex
index 6dbd45f..610c1ea 100644
--- a/lib/excoveralls/drone.ex
+++ b/lib/excoveralls/drone.ex
@@ -3,6 +3,7 @@ defmodule ExCoveralls.Drone do
   Handles drone-ci integration with coveralls.
   """
   alias ExCoveralls.Poster
+  alias ExCoveralls.Stats
 
   def execute(stats, options) do
     json = generate_json(stats, Enum.into(options, %{}))
@@ -20,7 +21,7 @@ defmodule ExCoveralls.Drone do
       service_number: get_build_num(),
       service_job_id: get_build_num(),
       service_pull_request: get_pull_request(),
-      source_files: stats,
+      source_files: Stats.serialize(stats),
       git: generate_git_info(),
       parallel: options[:parallel],
       flag_name: options[:flagname]
diff --git a/lib/excoveralls/github.ex b/lib/excoveralls/github.ex
index f5a04fb..4c84b4a 100644
--- a/lib/excoveralls/github.ex
+++ b/lib/excoveralls/github.ex
@@ -3,6 +3,7 @@ defmodule ExCoveralls.Github do
   Handles GitHub Actions integration with coveralls.
   """
   alias ExCoveralls.Poster
+  alias ExCoveralls.Stats
 
   def execute(stats, options) do
     json = generate_json(stats, Enum.into(options, %{}))
@@ -20,7 +21,7 @@ defmodule ExCoveralls.Github do
     %{
       repo_token: get_env("GITHUB_TOKEN"),
       service_name: "github",
-      source_files: stats,
+      source_files: Stats.serialize(stats),
       parallel: options[:parallel],
       flag_name: options[:flagname],
       git: git_info()
diff --git a/lib/excoveralls/gitlab.ex b/lib/excoveralls/gitlab.ex
index b801d97..0dda681 100644
--- a/lib/excoveralls/gitlab.ex
+++ b/lib/excoveralls/gitlab.ex
@@ -3,6 +3,7 @@ defmodule ExCoveralls.Gitlab do
   Handles gitlab-ci integration with coveralls.
   """
   alias ExCoveralls.Poster
+  alias ExCoveralls.Stats
 
   def execute(stats, options) do
     json = generate_json(stats, Enum.into(options, %{}))
@@ -23,7 +24,7 @@ defmodule ExCoveralls.Gitlab do
       service_number: get_number(),
       service_job_id: get_job_id(),
       service_pull_request: get_pull_request(),
-      source_files: stats,
+      source_files: Stats.serialize(stats),
       git: generate_git_info(),
       parallel: options[:parallel],
       flag_name: options[:flagname]
diff --git a/lib/excoveralls/ignore.ex b/lib/excoveralls/ignore.ex
index 4d6465b..3663a75 100644
--- a/lib/excoveralls/ignore.ex
+++ b/lib/excoveralls/ignore.ex
@@ -10,51 +10,167 @@ defmodule ExCoveralls.Ignore do
     Enum.map(info, &do_filter/1)
   end
 
+  defmodule State do
+    defstruct ignore_mode: :no_ignore,
+              coverage: [],
+              coverage_buffer: [],
+              warnings: [],
+              last_marker_index: nil
+  end
+
   defp do_filter(%{name: name, source: source, coverage: coverage}) do
-    lines = String.split(source, "\n")
-    list = Enum.zip(lines, coverage)
-           |> Enum.map_reduce(:no_ignore, &check_and_swap/2)
-           |> elem(0)
-           |> List.zip
-           |> Enum.map(&Tuple.to_list(&1))
+    source_lines = String.split(source, "\n")
 
-    [source, coverage] = parse_filter_list(list)
-    %{name: name, source: source, coverage: coverage}
-  end
+    processing_result =
+      Enum.zip(source_lines, coverage)
+      |> Enum.with_index()
+      |> Enum.reduce(%State{}, &process_line/2)
+      |> process_end_of_file()
 
-  defp check_and_swap({line, coverage}, ignore) do
-    {
-      coverage_for_line({line, coverage}, ignore),
-      ignore_next?(line, ignore)
-    }
+    updated_coverage = processing_result.coverage |> List.flatten() |> Enum.reverse()
+    warnings = Enum.sort_by(processing_result.warnings, &elem(&1, 0))
+    %{name: name, source: source, coverage: updated_coverage, warnings: warnings}
   end
 
-  defp parse_filter_list([]),   do: ["", []]
-  defp parse_filter_list([lines, coverage]), do: [Enum.join(lines, "\n"), coverage]
-
-  defp coverage_for_line({line, coverage}, ignore) do
-    if ignore == :no_ignore do
-      {line, coverage}
-    else
-      {line, nil}
+  defp process_line({{source_line, coverage_line}, index}, state) do
+    case detect_ignore_marker(source_line) do
+      :none -> process_regular_line(coverage_line, index, state)
+      :start -> process_start_marker(coverage_line, index, state)
+      :stop -> process_stop_marker(coverage_line, index, state)
+      :next_line -> process_next_line_marker(coverage_line, index, state)
     end
   end
 
-  defp ignore_next?(line, ignore) do
+  defp detect_ignore_marker(line) do
     case Regex.run(~r/coveralls-ignore-(start|stop|next-line)/, line, capture: :all_but_first) do
-      ["start"] -> :ignore_block
-      ["stop"] -> :no_ignore
-      ["next-line"] ->
-        case ignore do
-          :ignore_block -> ignore
-          _sth -> :ignore_line
-        end
-      _sth ->
-        case ignore do
-          :ignore_line -> :no_ignore
-          _sth -> ignore
-        end
+      ["start"] -> :start
+      ["stop"] -> :stop
+      ["next-line"] -> :next_line
+      _sth -> :none
     end
   end
 
+  defp process_regular_line(
+         coverage_line,
+         _index,
+         state = %{ignore_mode: :no_ignore, coverage_buffer: []}
+       ) do
+    %{state | coverage: [coverage_line | state.coverage]}
+  end
+
+  defp process_regular_line(_coverage_line, _index, state = %{ignore_mode: :ignore_line}) do
+    %{state | ignore_mode: :no_ignore, coverage: [nil | state.coverage]}
+  end
+
+  defp process_regular_line(_coverage_line, _index, state = %{ignore_mode: :ignore_block}) do
+    %{state | coverage: [nil | state.coverage]}
+  end
+
+  defp process_start_marker(
+         _coverage_line,
+         index,
+         state = %{ignore_mode: :no_ignore}
+       ) do
+    %{
+      state
+      | ignore_mode: :ignore_block,
+        coverage: [nil | state.coverage],
+        last_marker_index: index
+    }
+  end
+
+  defp process_start_marker(_coverage_line, index, state = %{ignore_mode: :ignore_block}) do
+    warning = {index, "unexpected ignore-start or missing previous ignore-stop"}
+
+    %{
+      state
+      | coverage: [nil | state.coverage],
+        warnings: [warning | state.warnings],
+        last_marker_index: index
+    }
+  end
+
+  defp process_start_marker(_coverage_line, index, state = %{ignore_mode: :ignore_line}) do
+    warning = {state.last_marker_index, "redundant ignore-next-line right before an ignore-start"}
+
+    %{
+      state
+      | ignore_mode: :ignore_block,
+        coverage: [nil | state.coverage],
+        warnings: [warning | state.warnings],
+        last_marker_index: index
+    }
+  end
+
+  defp process_stop_marker(_coverage_line, index, state = %{ignore_mode: :ignore_block}) do
+    %{
+      state
+      | ignore_mode: :no_ignore,
+        coverage: [nil | state.coverage],
+        last_marker_index: index
+    }
+  end
+
+  defp process_stop_marker(_coverage_line, index, state) do
+    warning = {index, "unexpected ignore-stop or missing previous ignore-start"}
+
+    %{
+      state
+      | ignore_mode: :no_ignore,
+        coverage: [nil | state.coverage],
+        warnings: [warning | state.warnings],
+        last_marker_index: index
+    }
+  end
+
+  defp process_next_line_marker(
+         _coverage_line,
+         index,
+         state = %{ignore_mode: :no_ignore}
+       ) do
+    %{
+      state
+      | ignore_mode: :ignore_line,
+        coverage: [nil | state.coverage],
+        last_marker_index: index
+    }
+  end
+
+  defp process_next_line_marker(
+         _coverage_line,
+         index,
+         state = %{ignore_mode: :ignore_block}
+       ) do
+    warning = {index, "redundant ignore-next-line inside ignore block"}
+
+    %{
+      state
+      | coverage: [nil | state.coverage],
+        warnings: [warning | state.warnings]
+    }
+  end
+
+  defp process_next_line_marker(
+         _coverage_line,
+         index,
+         state = %{ignore_mode: :ignore_line}
+       ) do
+    warning = {index, "duplicated ignore-next-line"}
+
+    %{
+      state
+      | coverage: [nil | state.coverage],
+        warnings: [warning | state.warnings],
+        last_marker_index: index
+    }
+  end
+
+  defp process_end_of_file(state = %{ignore_mode: :ignore_block}) do
+    warning =
+      {state.last_marker_index, "ignore-start without a corresponding ignore-stop"}
+
+    %{state | warnings: [warning | state.warnings]}
+  end
+
+  defp process_end_of_file(state), do: state
 end
diff --git a/lib/excoveralls/json.ex b/lib/excoveralls/json.ex
index 6b8bc54..633bc04 100644
--- a/lib/excoveralls/json.ex
+++ b/lib/excoveralls/json.ex
@@ -2,6 +2,7 @@ defmodule ExCoveralls.Json do
   @moduledoc """
   Generate JSON output for results.
   """
+  alias ExCoveralls.Stats
 
   @file_name "excoveralls.json"
 
@@ -16,7 +17,7 @@ defmodule ExCoveralls.Json do
 
   def generate_json(stats, _options) do
     Jason.encode!(%{
-      source_files: stats
+      source_files: Stats.serialize(stats)
     })
   end
 
diff --git a/lib/excoveralls/local.ex b/lib/excoveralls/local.ex
index f262d79..ee2a41f 100644
--- a/lib/excoveralls/local.ex
+++ b/lib/excoveralls/local.ex
@@ -46,6 +46,7 @@ defmodule ExCoveralls.Local do
     enabled = ExCoveralls.Settings.get_print_summary
     if enabled and not ExCoveralls.ConfServer.summary_printed?() do
       coverage(stats, options) |> IO.puts()
+      warnings(stats) |> IO.write()
       ExCoveralls.ConfServer.summary_printed()
     end
   end
@@ -92,6 +93,12 @@ defmodule ExCoveralls.Local do
     end
   end
 
+  def warnings(stats) do
+    for stat <- stats, {line_num, message} <- stat[:warnings], into: "" do
+      print_string("\e[33mwarning:\e[m ~s\n  ~s:~b\n", [message, stat[:name], line_num + 1])
+    end
+  end
+
   defp sort(count_info, options) do
     if options[:sort] do
       sort_order = parse_sort_options(options)
diff --git a/lib/excoveralls/post.ex b/lib/excoveralls/post.ex
index 0f546f8..e5982e5 100644
--- a/lib/excoveralls/post.ex
+++ b/lib/excoveralls/post.ex
@@ -3,6 +3,7 @@ defmodule ExCoveralls.Post do
   Handles general-purpose CI integration with coveralls.
   """
   alias ExCoveralls.Poster
+  alias ExCoveralls.Stats
 
   def execute(stats, options) do
     json = generate_json(stats, options)
@@ -17,7 +18,7 @@ defmodule ExCoveralls.Post do
       repo_token: options[:token],
       service_name: options[:service_name],
       service_number: options[:service_number],
-      source_files: source_info,
+      source_files: Stats.serialize(source_info),
       parallel: options[:parallel],
       flag_name: options[:flagname],
       git: generate_git_info(options)
diff --git a/lib/excoveralls/semaphore.ex b/lib/excoveralls/semaphore.ex
index 5a91fc0..024c172 100644
--- a/lib/excoveralls/semaphore.ex
+++ b/lib/excoveralls/semaphore.ex
@@ -3,6 +3,7 @@ defmodule ExCoveralls.Semaphore do
   Handles semaphore-ci integration with coveralls.
   """
   alias ExCoveralls.Poster
+  alias ExCoveralls.Stats
 
   def execute(stats, options) do
     json = generate_json(stats, Enum.into(options, %{}))
@@ -20,7 +21,7 @@ defmodule ExCoveralls.Semaphore do
       service_number: get_build_num(),
       service_job_id: get_build_num(),
       service_pull_request: get_pull_request(),
-      source_files: stats,
+      source_files: Stats.serialize(stats),
       git: generate_git_info(),
       parallel: options[:parallel],
       flag_name: options[:flagname]
diff --git a/lib/excoveralls/stats.ex b/lib/excoveralls/stats.ex
index eb504ee..7fe4a03 100644
--- a/lib/excoveralls/stats.ex
+++ b/lib/excoveralls/stats.ex
@@ -223,6 +223,14 @@ defmodule ExCoveralls.Stats do
     %Line{coverage: Enum.at(coverage, i) , source: line}
   end
 
+  @doc """
+  Converts coverage stats to a map, which can be serialized to JSON
+  for posting it to Coveralls or writing to excoveralls.json.
+  """
+  def serialize(stats) do
+    Enum.map(stats, &Map.take(&1, [:name, :source, :coverage]))
+  end
+
   @doc """
   Exit the process with a status of 1 if coverage is below the minimum.
   """
diff --git a/lib/excoveralls/travis.ex b/lib/excoveralls/travis.ex
index 966b02c..00e54b2 100644
--- a/lib/excoveralls/travis.ex
+++ b/lib/excoveralls/travis.ex
@@ -3,6 +3,7 @@ defmodule ExCoveralls.Travis do
   Handles travis-ci integration with coveralls.
   """
   alias ExCoveralls.Poster
+  alias ExCoveralls.Stats
 
   def execute(stats, options) do
     json = generate_json(stats, Enum.into(options, %{}))
@@ -18,7 +19,7 @@ defmodule ExCoveralls.Travis do
       service_job_id: get_job_id(),
       service_name: "travis-pro",
       repo_token: get_repo_token(),
-      source_files: stats,
+      source_files: Stats.serialize(stats),
       git: generate_git_info()
     })
   end
@@ -26,7 +27,7 @@ defmodule ExCoveralls.Travis do
     Jason.encode!(%{
       service_job_id: get_job_id(),
       service_name: "travis-ci",
-      source_files: stats,
+      source_files: Stats.serialize(stats),
       git: generate_git_info()
     })
   end


Test patch:
diff --git a/test/circle_test.exs b/test/circle_test.exs
index 8c40471..a29ddc2 100644
--- a/test/circle_test.exs
+++ b/test/circle_test.exs
@@ -7,7 +7,8 @@ defmodule ExCoveralls.CircleTest do
   @counts      [0, 1, nil, nil]
   @source_info [%{name: "test/fixtures/test.ex",
                   source: @content,
-                  coverage: @counts
+                  coverage: @counts,
+                  warnings: []
                }]
 
   setup do
diff --git a/test/cobertura_test.exs b/test/cobertura_test.exs
index 4a78234..9176a2e 100644
--- a/test/cobertura_test.exs
+++ b/test/cobertura_test.exs
@@ -9,7 +9,11 @@ defmodule ExCoveralls.CoberturaTest do
 
   @content "defmodule Test do\n  def test do\n  end\nend\n"
   @counts [0, 1, nil, nil]
-  @source_info [%{name: "test/fixtures/test.ex", source: @content, coverage: @counts}]
+  @source_info [%{name: "test/fixtures/test.ex",
+                  source: @content,
+                  coverage: @counts,
+                  warnings: []
+               }]
 
   @stats_result "" <>
                   "----------------\n" <>
@@ -206,7 +210,11 @@ defmodule ExCoveralls.CoberturaTest do
     get_print_files: fn -> true end do
     content = "defprotocol TestProtocol do\n  def test(value)\nend\n"
     counts = [0, 1, nil, nil]
-    source_info = [%{name: "test/fixtures/test_protocol.ex", source: content, coverage: counts}]
+    source_info = [%{name: "test/fixtures/test_protocol.ex",
+                     source: content,
+                     coverage: counts,
+                     warnings: []
+                  }]
 
     stats_result =
       "" <>
@@ -226,7 +234,11 @@ defmodule ExCoveralls.CoberturaTest do
     get_print_files: fn -> true end do
     content = "defimpl TestProtocol, for: Integer do\n  def test(value), do: \"integer!\" \nend\n"
     counts = [0, 1, nil, nil]
-    source_info = [%{name: "test/fixtures/test_impl.ex", source: content, coverage: counts}]
+    source_info = [%{name: "test/fixtures/test_impl.ex",
+                     source: content,
+                     coverage: counts,
+                     warnings: []
+                  }]
 
     stats_result =
       "" <>
diff --git a/test/drone_test.exs b/test/drone_test.exs
index 37cbe7e..0d79004 100644
--- a/test/drone_test.exs
+++ b/test/drone_test.exs
@@ -7,7 +7,8 @@ defmodule ExCoveralls.DroneTest do
   @counts      [0, 1, nil, nil]
   @source_info [%{name: "test/fixtures/test.ex",
                  source: @content,
-                 coverage: @counts
+                 coverage: @counts,
+                 warnings: []
                }]
 
   setup do
diff --git a/test/github_test.exs b/test/github_test.exs
index 429ccf6..94b8627 100644
--- a/test/github_test.exs
+++ b/test/github_test.exs
@@ -5,7 +5,12 @@ defmodule ExCoveralls.GithubTest do
 
   @content "defmodule Test do\n  def test do\n  end\nend\n"
   @counts [0, 1, nil, nil]
-  @source_info [%{name: "test/fixtures/test.ex", source: @content, coverage: @counts}]
+  @source_info [%{name: "test/fixtures/test.ex",
+                  source: @content,
+                  coverage: @counts,
+                  warnings: []
+               }]
+
   setup do
     # No additional context
     github_event_path = System.get_env("GITHUB_EVENT_PATH")
diff --git a/test/gitlab_test.exs b/test/gitlab_test.exs
index 5d81972..674ca74 100644
--- a/test/gitlab_test.exs
+++ b/test/gitlab_test.exs
@@ -5,7 +5,11 @@ defmodule ExCoveralls.GitlabTest do
 
   @content "defmodule Test do\n  def test do\n  end\nend\n"
   @counts [0, 1, nil, nil]
-  @source_info [%{name: "test/fixtures/test.ex", source: @content, coverage: @counts}]
+  @source_info [%{name: "test/fixtures/test.ex",
+                  source: @content,
+                  coverage: @counts,
+                  warnings: []
+               }]
 
   setup do
     # Capture existing values
diff --git a/test/html_test.exs b/test/html_test.exs
index c78ae79..9b785e7 100644
--- a/test/html_test.exs
+++ b/test/html_test.exs
@@ -13,7 +13,8 @@ defmodule ExCoveralls.HtmlTest do
   @counts      [0, 1, nil, nil]
   @source_info [%{name: "test/fixtures/test.ex",
                  source: @content,
-                 coverage: @counts
+                 coverage: @counts,
+                 warnings: []
                }]
 
   @stats_result "" <>
diff --git a/test/ignore_test.exs b/test/ignore_test.exs
index c661f5b..f358730 100644
--- a/test/ignore_test.exs
+++ b/test/ignore_test.exs
@@ -2,7 +2,7 @@ defmodule ExCoveralls.IgnoreTest do
   use ExUnit.Case
   alias ExCoveralls.Ignore
 
-  @block_content     """
+  @content     """
   defmodule Test do
     def test do
     end
@@ -12,13 +12,20 @@ defmodule ExCoveralls.IgnoreTest do
     #coveralls-ignore-stop
   end
   """
-  @block_counts      [0, 0, 0, nil, 0, 0, nil, 0, 0]
-  @block_source_info [%{name: "test/fixtures/test.ex",
-                 source: @block_content,
-                 coverage: @block_counts
+  @counts      [0, 0, 0, nil, 0, 0, nil, 0, 0]
+  @source_info [%{name: "test/fixtures/test.ex",
+                 source: @content,
+                 coverage: @counts
                }]
 
-  @single_line_content     """
+  test "filter ignored lines with start/stop block returns valid list" do
+    info = Ignore.filter(@source_info) |> Enum.at(0)
+    assert(info[:source]   == @content)
+    assert(info[:coverage] == [0, 0, 0, nil, nil, nil, nil, 0, 0])
+    assert(info[:warnings] == [])
+  end
+
+  @content     """
   defmodule Test do
     def test do
     end
@@ -29,13 +36,20 @@ defmodule ExCoveralls.IgnoreTest do
     end
   end
   """
-  @single_line_counts      [0, 0, 0, nil, 0, 0, 0, 0, 0, 0]
-  @single_line_source_info [%{name: "test/fixtures/test.ex",
-                 source: @single_line_content,
-                 coverage: @single_line_counts
+  @counts      [0, 0, 0, nil, 0, 0, 0, 0, 0, 0]
+  @source_info [%{name: "test/fixtures/test.ex",
+                 source: @content,
+                 coverage: @counts
                }]
 
-  @mixed_content     """
+  test "filter ignored lines with next-line returns valid list" do
+    info = Ignore.filter(@source_info) |> Enum.at(0)
+    assert(info[:source]   == @content)
+    assert(info[:coverage] == [0, 0, 0, nil, nil, 0, 0, 0, 0, 0])
+    assert(info[:warnings] == [])
+  end
+
+  @content     """
   defmodule Test do
     def test do
     end
@@ -48,27 +62,89 @@ defmodule ExCoveralls.IgnoreTest do
     end
   end
   """
-  @mixed_counts      [0, 0, 0, nil, 0, nil, 0, nil, 0, 0, 0, 0]
-  @mixed_source_info [%{name: "test/fixtures/test.ex",
-                 source: @mixed_content,
-                 coverage: @mixed_counts
+  @counts      [0, 0, 0, nil, 0, nil, 0, nil, 0, 0, 0, 0]
+  @source_info [%{name: "test/fixtures/test.ex",
+                 source: @content,
+                 coverage: @counts
                }]
 
-  test "filter ignored lines with start/stop block returns valid list" do
-    info = Ignore.filter(@block_source_info) |> Enum.at(0)
-    assert(info[:source]   == @block_content)
-    assert(info[:coverage] == [0, 0, 0, nil, nil, nil, nil, 0, 0])
+  test "next-line marker inside start/stop block produces warning" do
+    info = Ignore.filter(@source_info) |> Enum.at(0)
+    assert(info[:source]   == @content)
+    assert(info[:coverage] == [0, 0, 0, nil, nil, nil, nil, nil, 0, 0, 0, 0])
+    assert(info[:warnings] == [{5, "redundant ignore-next-line inside ignore block"}])
   end
 
-  test "filter ignored lines with next-line returns valid list" do
-    info = Ignore.filter(@single_line_source_info) |> Enum.at(0)
-    assert(info[:source]   == @single_line_content)
-    assert(info[:coverage] == [0, 0, 0, nil, nil, 0, 0, 0, 0, 0])
+  @content     """
+  defmodule Test do
+    #coveralls-ignore-next-line
+    #coveralls-ignore-next-line
   end
+  """
+  @counts      [0, nil, nil, 0, 0]
+  @source_info [%{name: "test/fixtures/test.ex",
+                 source: @content,
+                 coverage: @counts
+               }]
 
-  test "filter ignored lines with next-line inside start/stop block returns valid list" do
-    info = Ignore.filter(@mixed_source_info) |> Enum.at(0)
-    assert(info[:source]   == @mixed_content)
-    assert(info[:coverage] == [0, 0, 0, nil, nil, nil, nil, nil, 0, 0, 0, 0])
+  test "next-line marker right after another next-line marker produces warning" do
+    info = Ignore.filter(@source_info) |> Enum.at(0)
+    assert(info[:source]   == @content)
+    assert(info[:coverage] == [0, nil, nil, nil, 0])
+    assert(info[:warnings] == [{2, "duplicated ignore-next-line"}])
+  end
+  
+  @content     """
+  defmodule Test do
+    def test do
+    end
+    #coveralls-ignore-start
+    def test do
+    end
+    #coveralls-ignore-stop
+    def test_not_ignored do
+    end
+    #coveralls-ignore-start
+    def test_missing_stop
+    end
+  end
+  """
+  @counts     [0, 0, 0, nil, 0, 0, nil, 0, 0, nil, 0, 0, 0, 0]
+  @source_info [%{name: "test/fixtures/test.ex",
+                 source: @content,
+                 coverage: @counts
+               }]
+
+  test "start marker without a stop marker produces warning" do
+    info = Ignore.filter(@source_info) |> Enum.at(0)
+    assert(info[:source]   == @content)
+    assert(info[:coverage] == [0, 0, 0, nil, nil, nil, nil, 0, 0, nil, nil, nil, nil, nil])
+     assert(info[:warnings] == [{9, "ignore-start without a corresponding ignore-stop"}])
+  end
+
+  @content     """
+  defmodule Test do
+    def test do
+    end
+    #coveralls-ignore-start
+    def test do
+    end
+    #coveralls-ignore-start
+    def test_ignore
+    end
+    #coveralls-ignore-stop
+  end
+  """
+  @counts     [0, 0, 0, nil, 0, 0, nil, 0, 0, nil, 0, 0]
+  @source_info [%{name: "test/fixtures/test.ex",
+                 source: @content,
+                 coverage: @counts
+               }]
+
+  test "start marker followed by another start marker produces warning" do
+    info = Ignore.filter(@source_info) |> Enum.at(0)
+    assert(info[:source]   == @content)
+    assert(info[:coverage] == [0, 0, 0, nil, nil, nil, nil, nil, nil, nil, 0, 0])
+    assert(info[:warnings] == [{6, "unexpected ignore-start or missing previous ignore-stop"}])
   end
 end
diff --git a/test/json_test.exs b/test/json_test.exs
index d384870..92d6f16 100644
--- a/test/json_test.exs
+++ b/test/json_test.exs
@@ -12,7 +12,8 @@ defmodule ExCoveralls.JsonTest do
   @counts      [0, 1, nil, nil]
   @source_info [%{name: "test/fixtures/test.ex",
                   source: @content,
-                  coverage: @counts
+                  coverage: @counts,
+                  warnings: []
                }]
 
   @stats_result "" <>
diff --git a/test/lcov_test.exs b/test/lcov_test.exs
index 25c59ec..ac52896 100644
--- a/test/lcov_test.exs
+++ b/test/lcov_test.exs
@@ -12,7 +12,8 @@ defmodule ExCoveralls.LcovTest do
   @test_file_name "test/fixtures/test.ex"
   @source_info [%{name: @test_file_name,
                   source: @content,
-                  coverage: @counts
+                  coverage: @counts,
+                  warnings: []
                }]
 
   @stats_result "" <>
diff --git a/test/local_test.exs b/test/local_test.exs
index 27cfc7f..db74d57 100644
--- a/test/local_test.exs
+++ b/test/local_test.exs
@@ -8,21 +8,32 @@ defmodule ExCoveralls.LocalTest do
   @counts      [0, 1, nil, nil]
   @source_info [%{name: "test/fixtures/test.ex",
                  source: @content,
-                 coverage: @counts
+                 coverage: @counts,
+                 warnings: []
                }]
 
   @invalid_counts [0, 1, nil, "invalid"]
   @invalid_source_info [%{name: "test/fixtures/test.ex",
                  source: @content,
-                 coverage: @invalid_counts
+                 coverage: @invalid_counts,
+                 warnings: []
                }]
 
   @empty_counts [nil, nil, nil, nil]
   @empty_source_info [%{name: "test/fixtures/test.ex",
                  source: @content,
-                 coverage: @empty_counts
+                 coverage: @empty_counts,
+                 warnings: []
                }]
 
+  @warning_source_info [%{
+                 name: "test/fixtures/test.ex",
+                 source: @content,
+                 coverage: @counts,
+                 warnings: [{2, "this is a test"}, {4, "another test"}]
+               }]
+
+
   @stats_result "" <>
       "----------------\n" <>
       "COV    FILE                                        LINES RELEVANT   MISSED\n" <>
@@ -38,6 +49,12 @@ defmodule ExCoveralls.LocalTest do
       "  end\n" <>
       "end"
 
+  @warning_result "" <>
+      "\n\e[33mwarning:\e[m this is a test\n" <>
+      "  test/fixtures/test.ex:3\n" <>
+      "\e[33mwarning:\e[m another test\n" <>
+      "  test/fixtures/test.ex:5\n"
+
   setup do
     ExCoveralls.ConfServer.clear()
     on_exit(fn -> ExCoveralls.ConfServer.clear() end)
@@ -74,6 +91,12 @@ defmodule ExCoveralls.LocalTest do
     end
   end
 
+  test "display warnings" do
+    assert capture_io(fn ->
+      Local.execute(@warning_source_info)
+    end) =~ @warning_result
+  end
+
   test "Empty (no relevant lines) file is calculated as 0.0%" do
     assert String.contains?(Local.coverage(@empty_source_info), "[TOTAL]   0.0%")
   end
diff --git a/test/post_test.exs b/test/post_test.exs
index fd62b8e..85b5274 100644
--- a/test/post_test.exs
+++ b/test/post_test.exs
@@ -7,7 +7,8 @@ defmodule ExCoveralls.PostTest do
   @counts      [0, 1, nil, nil]
   @source_info [%{name: "test/fixtures/test.ex",
                   source: @content,
-                  coverage: @counts
+                  coverage: @counts,
+                  warnings: []
                }]
 
   test_with_mock "execute", ExCoveralls.Poster, [execute: fn(_, _) -> "result" end] do
diff --git a/test/semaphore_test.exs b/test/semaphore_test.exs
index 161674a..7ff4917 100644
--- a/test/semaphore_test.exs
+++ b/test/semaphore_test.exs
@@ -5,7 +5,11 @@ defmodule ExCoveralls.SemaphoreTest do
 
   @content "defmodule Test do\n  def test do\n  end\nend\n"
   @counts [0, 1, nil, nil]
-  @source_info [%{name: "test/fixtures/test.ex", source: @content, coverage: @counts}]
+  @source_info [%{name: "test/fixtures/test.ex",
+                  source: @content,
+                  coverage: @counts,
+                  warnings: []
+               }]
 
   setup do
     # Capture existing values
diff --git a/test/travis_test.exs b/test/travis_test.exs
index caf4068..269db90 100644
--- a/test/travis_test.exs
+++ b/test/travis_test.exs
@@ -7,7 +7,8 @@ defmodule ExCoveralls.TravisTest do
   @counts      [0, 1, nil, nil]
   @source_info [%{name: "test/fixtures/test.ex",
                  source: @content,
-                 coverage: @counts
+                 coverage: @counts,
+                 warnings: []
                }]
 
   test_with_mock "execute", ExCoveralls.Poster, [execute: fn(_) -> "result" end] do
diff --git a/test/xml_test.exs b/test/xml_test.exs
index ad0e601..279ebe5 100644
--- a/test/xml_test.exs
+++ b/test/xml_test.exs
@@ -11,7 +11,8 @@ defmodule ExCoveralls.XmlTest do
   @counts      [0, 1, nil, nil]
   @source_info [%{name: "test/fixtures/test.ex",
                   source: @content,
-                  coverage: @counts
+                  coverage: @counts,
+                  warnings: []
                }]
 
   @stats_result "" <>