Skip to main content

lib/npm/ignore.ex

defmodule NPM.Ignore do
  @moduledoc """
  Parses .npmignore and .gitignore files for package publishing.

  Determines which files to include/exclude when packing or publishing
  a package, following npm's ignore rules.
  """

  @always_ignored ~w(.git .svn CVS .hg .DS_Store node_modules .npmrc)
  @never_ignored ~w(package.json README.md LICENSE LICENCE CHANGELOG.md)

  @doc """
  Reads and parses an ignore file.
  """
  @spec read(String.t()) :: [String.t()]
  def read(path) do
    case File.read(path) do
      {:ok, content} -> parse(content)
      _ -> []
    end
  end

  @doc """
  Parses ignore file content into a list of patterns.
  """
  @spec parse(String.t()) :: [String.t()]
  def parse(content) do
    content
    |> String.split("\n")
    |> Enum.map(&String.trim/1)
    |> Enum.reject(&comment_or_blank?(&1))
    |> Enum.uniq()
  end

  @doc """
  Checks if a file path is ignored by the given patterns.
  """
  @spec ignored?(String.t(), [String.t()]) :: boolean()
  def ignored?(path, patterns) do
    basename = Path.basename(path)

    cond do
      always_ignored?(basename) -> true
      never_ignored?(basename) -> false
      true -> Enum.any?(patterns, &pattern_matches?(path, &1))
    end
  end

  @doc """
  Returns the list of always-ignored paths.
  """
  @spec always_ignored :: [String.t()]
  def always_ignored, do: @always_ignored

  @doc """
  Returns the list of never-ignored paths.
  """
  @spec never_ignored :: [String.t()]
  def never_ignored, do: @never_ignored

  @doc """
  Gets the effective ignore patterns for a package directory.

  Checks .npmignore first, falls back to .gitignore.
  """
  @spec effective_patterns(String.t()) :: [String.t()]
  def effective_patterns(package_dir) do
    npmignore = Path.join(package_dir, ".npmignore")
    gitignore = Path.join(package_dir, ".gitignore")

    case read(npmignore) do
      [] -> read(gitignore)
      patterns -> patterns
    end
  end

  defp always_ignored?(name), do: name in @always_ignored

  defp never_ignored?(name) do
    downcased = String.downcase(name)
    Enum.any?(@never_ignored, &(String.downcase(&1) == downcased))
  end

  defp comment_or_blank?(line) do
    line == "" or String.starts_with?(line, "#")
  end

  defp pattern_matches?(path, pattern) do
    cond do
      String.ends_with?(pattern, "/") ->
        dir_pattern = String.trim_trailing(pattern, "/")
        String.starts_with?(path, dir_pattern <> "/") or path == dir_pattern

      String.contains?(pattern, "/") ->
        path == pattern or String.starts_with?(path, pattern <> "/")

      true ->
        Path.basename(path) == pattern or
          path == pattern or
          String.ends_with?(path, "/" <> pattern)
    end
  end
end