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

Support for Mix.exs project function changes #162

Open
epinault opened this issue Nov 22, 2024 · 6 comments
Open

Support for Mix.exs project function changes #162

epinault opened this issue Nov 22, 2024 · 6 comments
Labels
enhancement New feature or request

Comments

@epinault
Copy link

Is your feature request related to a problem? Please describe.

I could not find a way to update the options in the project function of a mix.exs file. Would be awesome if we could so we can update specific params or add/remove new options. I am currently trying to write an upgrade style igniter for phx 1.6to 1.7 and one of the change involve clean the :compilers option to remove phoenix and gettext too

Describe the solution you'd like

An api to modify or add/remote options for the project function in a mix.exs file

Describe alternatives you've considered

Dont have any other than using Zipper myself and figure it out

@epinault epinault added the enhancement New feature or request label Nov 22, 2024
@petermm
Copy link
Contributor

petermm commented Nov 28, 2024

Yeah, you end up figuring out your own zipper eg. https://github.com/atomvm/exatomvm/pull/32/files

    Igniter.update_elixir_file(igniter, "mix.exs", fn zipper ->
       with {:ok, zipper} <- Igniter.Code.Function.move_to_def(zipper, :project, 0),
            {:ok, zipper} <-
              Igniter.Code.Keyword.put_in_keyword(
                zipper,
                [:atomvm],
                start: Igniter.Project.Module.module_name_prefix(igniter),
                esp32_flash_offset: 0x250000,
                stm32_flash_offset: 0x8080000,
                chip: "auto",
                port: "auto"
              ) do
         {:ok, zipper}
       end
     end)

@zachallaun
Copy link
Collaborator

@zachdaniel I have a need for this as well, might go ahead and knock it out. I'm curious if you have opinions on where this stuff should live and what direction we should go in.

Some thoughts:

mix.exs tends to follow a specific pattern which could be used to provide useful, high-level tools for modifying it. You have the public functions (like project/0, application/0, cli/0) and then the private ones (like deps) that are called to populate the keyword lists returned from the public ones. (I digress, but this also means that Igniter.Project.Deps should really be looking at the value of :deps in project/0, and then go to that function call, which is usually deps/0.)

So at a high level, when you're modifying mix.exs, you really care about the config path and it would be nice if Igniter took care of navigating (or creating) whatever nested structures get you there. For instance, perhaps something like this would be useful:

Igniter.Project.MixProject.update(igniter, :project, [:aliases, :"some.task"], fn
  nil -> {:ok, ["some.task.one", "some.task.two"]}
  zipper -> {:ok, zipper}
end)

# above could be used to make higher-level general-purpose utils
Igniter.Project.MixProject.put_new_in(
  igniter,
  :project,
  [:aliases, :"some.task"],
  ["some.task.one", "some.task.two"]
)

In the above example, update would know how to navigate either of these:

def project do
  [
    aliases: [...]
  ]
end

# or

def project do
  [
    aliases: aliases()
  ]
end

defp aliases do
  [...]
end

For @petermm, the above snippet would become something like:

Igniter.Project.MixProject.put_in(igniter, :project, [:atomvm],
  start: Igniter.Project.Module.module_name_prefix(igniter),
  esp32_flash_offset: 0x250000,
  stm32_flash_offset: 0x8080000,
  chip: "auto",
  port: "auto"
)

Thoughts?

@zachallaun
Copy link
Collaborator

zachallaun commented Dec 13, 2024

To help think through this proposed API a bit more, I wrote out a docstring and spec:

  @doc """
  Updates the project configuration AST at the given path.

  This function accepts a `function_name` atom corresponding to a function
  like `project/0`, `application/0`, or `cli/0` and navigates to the given
  path, jumping to private functions if necessary and creating nested
  keyword lists if they don't already exist. It then calls the given
  `update_fun`, using the return value to update the AST.

  `update_fun` must be a function that accepts one argument, a zipper
  targeting the current AST at the given configuration path or `nil` if
  there was no value at that path. It then must return one of the
  following:

    * `{:ok, zipper}` - the updated zipper
    * `{:ok, {:code, quoted}}` - a quoted expression that should be
      inserted as the new value at `path`
    * `{:ok, nil}` - indicates that the last key in `path` should be
      removed
    * `{:error, message}` - an error that will be accumulated in the
      `igniter`
    * `{:warning, message}` - a warning that will be accumulated in the
      `igniter`
    * `:error` - the `igniter` should be returned without change

  ## Examples

  Assuming a newly-generated Mix project that looks like this:

      defmodule Example.MixProject do
        use Mix.Project

        def project do
          [
            app: :example,
            version: "0.1.0",
            elixir: "~> 1.17",
            start_permanent: Mix.env() == :prod,
            deps: deps()
          ]
        end

        def application do
          [
            extra_applications: [:logger]
          ]
        end

        defp deps do
          []
        end
      end

  Increment the project version by one patch level:

      Igniter.Project.MixProject.update(igniter, :project, [:version], fn zipper ->
        new_version =
          zipper.node
          |> Version.parse!()
          |> Map.update!(:patch, &(&1 + 1))
          |> to_string()

        {:ok, {:code, new_version}}
      end)

      # would result in
      def project do
        [
          ...,
          version: "0.1.1",
          ...
        ]
      end

  Set the preferred env for a task to `:test`:

      Igniter.Project.MixProject.update(
        igniter,
        :cli,
        [:preferred_envs, :"some.task"],
        fn _ -> {:ok, {:code, :test}} end
      )

      # would create `cli/0` and set the env:
      def cli do
        [
          preferred_envs: [
            "some.task": :test
          ]
        ]
      end

  Add `:some_application` to `:extra_applications`:

      Igniter.Project.MixProject.update(igniter, :application, [:extra_applications], fn
        nil -> {:ok, {:code, [:some_application]}}
        zipper -> Igniter.Code.List.append_to_list(zipper, :some_application)
      end)

      # would result in
      def application do
        [
          extra_applications: [:logger, :some_application]
        ]
      end

  Remove `:extra_applications` altogether:

      Igniter.Project.MixProject.update(
        igniter,
        :application,
        [:extra_applications],
        fn _ -> {:ok, nil} end
      )

      # would result in
      def application do
        []
      end

  """
  @spec update(
          Igniter.t(),
          function_name :: atom(),
          path :: nonempty_list(atom()),
          update_fun ::
            (Zipper.t() | nil ->
               {:ok, Zipper.t() | {:code, quoted :: Macro.t()} | nil}
               | {:error | :warning, term()}
               | :error)
        ) :: Igniter.t()
  def update(%Igniter{} = igniter, function_name, path, update_fun)
      when is_atom(function_name) and is_list(path) and is_function(update_fun, 1) do
    ...
  end

@zachdaniel
Copy link
Contributor

🤔 its an interesting case. Honestly I'm not sure how ultimately successful this abstraction will be. There is enough variation in the "typical ways" that something might be configured, that I feel like supporting things like set_project_option will be easier in the long run?

For example, the code in Igniter.Project.TaskAliases provides a nice ergonomic way of manipulating aliases that handles things for you.

With that said, I'm open to having this to start, and then advising people to use other more specific functions as they are developed.

@zachdaniel
Copy link
Contributor

The project version is actually a good example of such a thing, specifically its very common for the project version to live in @version

@zachallaun
Copy link
Collaborator

zachallaun commented Dec 14, 2024

I agree with you that higher-level abstractions should be built.

Another is :preferred_envs in cli/0; pre-1.15, this was :preferred_cli_env in project/0. So what you'd really want is a function like add_preferred_env(igniter, :"some.task", :test) that first looks for :preferred_envs, then :preferred_cli_env, then falls back to creating the correct one depending on which version of Elixir the user is on. (This is actually my own use case and the reason I picked this feature up!)

That said, I'm not yet convinced that @version should be treated specially. I also use a module attribute for @source_url in Mneme. If I was updating it, like update(igniter, :project, [:docs, :source_url], fn ... end), I'd want the update to apply to the value of the module attribute as well.

My strong inclination is to implement update/4 first and see how it feels with a bunch of test cases. If it ends up being generally useful, we can ship it. If there end up being a bunch of foot guns, we can not ship it, make it private, and use it to build out higher-level stuff that handles all those special cases.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
Development

No branches or pull requests

4 participants