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

scalafixOnCompile setting key #140

Merged
merged 3 commits into from
Jul 13, 2020
Merged

Conversation

bjaglin
Copy link
Collaborator

@bjaglin bjaglin commented Jul 5, 2020

Users can control whether Scalafix should be run (with the rules defined in .scalafix.conf) as part of compile,at the project & configuration level.

The value of scalafixOnCompile is ignored when invoking directly scalafix or scalafixAll, as the CLI arguments (if there are any) take precedence over the default rules. That means for example that scalafix --check is safe to run even though scalafixOnCompile := true, as there will not be any rewrite triggered by the implicit compilation.

Enabling scalafixOnCompile wil automatically turn on caching, which can be disabled by an explicit scalafixCaching := false.

@bjaglin bjaglin force-pushed the scalafixOnCompile branch 2 times, most recently from 0e81719 to eb0f7cc Compare July 5, 2020 15:18
@bjaglin bjaglin marked this pull request as ready for review July 5, 2020 16:18
@bjaglin bjaglin requested review from olafurpg and mlachkar July 5, 2020 16:18
Comment on lines 105 to 118
// We can't just run scalafix from here, as it would introduce a cyclic dependency
// since semantic rules require compilation products. Instead we run scalafix within
// a custom state where compile has not been overriden.
val extracted = Project.extract(currentState)
val withOldCompile = Compat.append(
extracted,
Seq(compile.in(ref, config) := oldCompile),
currentState
)
extracted.runInputTask(
scalafix.in(ref, config),
input = "",
withOldCompile
)
Copy link
Collaborator Author

@bjaglin bjaglin Jul 5, 2020

Choose a reason for hiding this comment

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

This seems to work, but it is very hacky, as:

  1. the state "reload" is slow
  2. compilations for a given project/config and most annoyingly all the projects it depends on are not de-duped during the liftetime of a task execution

Still trying to come up with something better (forcing the fullClasspath value lookup to transitively depend on a non-"custom" compile).

Copy link

Choose a reason for hiding this comment

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

2. compilations for a given project/config and most annoyingly all the projects it depends on are not de-duped during the liftetime of a task execution

Yes, because runInputTask is running outside of the task graph, which is dangerous for race conditions. See these references for when sbt-buildinfo used to do the same thing, and the problems it caused: sbt/sbt-buildinfo@7f3a548#diff-0a369498a5a8db3ac8fa606b544c9810

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Thanks for the pointers, that confirms my concerns! How did you get rid of it there? I am just wondering if it would be applicable here.

Copy link

Choose a reason for hiding this comment

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

I changed the implementation so the values were defined within the Task (monad): sbt/sbt-buildinfo@v0.7.0...v0.9.0#diff-60231996cd2e40cc694705deb78dc6d6R27; notice the case BuildInfoKey.TaskValue, which doesn't do the unsafe extracted.runTask (the case BuildInfoKey.Task is still there for backwards-compatibility and extreme cases.)

if (runScalafixAfterCompile) {
// We can't just run scalafix from here, as it would introduce a cyclic dependency
// since semantic rules require compilation products. Instead we run scalafix within
// a custom state where compile has not been overriden.
Copy link

Choose a reason for hiding this comment

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

Where's the cycle? val oldCompile = compile.value means "compile and store the result (CompileAnalysis) as oldCompile. So scalafix should find all the compilation products it needs in the right places in the target directories.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

on master: scalafix -> fullClasspath -> compile
introduced in this PR: compile -> scalafix

Copy link

Choose a reason for hiding this comment

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

OK, instead of redefining compile, try something like:

scalafix := Def.taskDyn {
  // ...
  if (runScalafixAfterCompile)
    scalafix.triggeredBy(compile.in(config))
  else
    scalafix
}.value

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I tried that earlier but as far as I remember, it did not give me what I wanted: a failure in scalafix woudn't cascade to compile, given the fire-and-forget semantics of triggeredBy.

Copy link

Choose a reason for hiding this comment

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

I see, yes, that is to be expected.

So, thinking back, why does scalafix need fullClasspath but compile doesn't? It looks like (ignoring a bunch of noise and detail) compile is defined in terms of dependencyClasspath and classDirectory, and products (a dependency of fullClasspath) is defined as

  def makeProducts: Initialize[Task[Seq[File]]] = Def.task {
    compile.value
    copyResources.value
    classDirectory.value :: Nil
  }

So I wonder if your redefinition of compile could run old compile and copyResources, and then run scalafix, having redefined scalafix as depending on dependencyClasspath and classDirectory, the last of which should have the classfiles from compile and the resources from copyResources.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

having redefined scalafix as depending on dependencyClasspath and classDirectory, the last of which should have the classfiles from compile and the resources from copyResources.

By removing that dependency, the problem is that scalafix could run against stale class/semanticDB files, right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I guess there is something to do with scalafixRunExplicitly, which can control in which direction the dependency is so it's never cyclic for a given task execution, I'll give it a try.

Copy link

Choose a reason for hiding this comment

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

Another way to see it is: given scalafix will always depend on compilation, then "runScalafixAfterCompile" roughly means "redefine compile as scalafix".

Copy link
Collaborator Author

@bjaglin bjaglin Jul 8, 2020

Choose a reason for hiding this comment

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

I think I came up with a reasonable implementation. It is a bit more tricky than I anticipated because the real scalafix can run any rule given that it's a input key, while the scalafixOnCompile can only trigger a no-arg scalafix which uses .scalafix.conf to find rules to run. That's why I changed a bit the behavior (see last paragraph of first comment).

Is that what you had in mind?

Copy link

Choose a reason for hiding this comment

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

Yeah, looks good (only fast-scanned, this late at night 😄).

Nice to know it actually worked! (famous last words...)

@olafurpg
Copy link
Contributor

olafurpg commented Jul 6, 2020

Would it be possible to achieve similar functionality using triggeredBy? I'm couldn't get the following code to work as expected but I think the code looks something like this

    scalafix.in(Compile) :=
      scalafix.in(Compile).toTask(" ").triggeredBy(compile in Compile).value,

@bjaglin
Copy link
Collaborator Author

bjaglin commented Jul 6, 2020

Would it be possible to achieve similar functionality using triggeredBy?

@olafurpg see inline discussion: compile wouldn't fail on lint error. I naively expected this behavior for visibility, but maybe it's better that way?

@olafurpg
Copy link
Contributor

olafurpg commented Jul 6, 2020

My apologies! I missed that conversation, makes sense!

"To run on test sources use test:scalafix or scalafixAll."
"For example: scalafix RemoveUnused. " +
"To run on test sources use test:scalafix or scalafixAll. " +
"When invoked directly, prior compilation will be triggered for semantic rules."
Copy link
Collaborator Author

@bjaglin bjaglin Jul 8, 2020

Choose a reason for hiding this comment

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

Not very proud of the "When invoked directly" wording but I am trying to get the attention of power users that would call/use scalafix input keys from another context (like another input meta input key that would aggregate scalafmt & scalafix for example). In that case, scalafixRunExplicitly will be false, so Scalafix might run on stale semanticdb/class files. I haven't found a way to work around that, but I think it's such a corner case and the current implementation is quite readable that it's not worth trying to address it.

Users can control whether scalafix (running the default rules declared
in .scalafix.conf) should be run as part of `compile` at the project &
configuration level.

The value of scalafixOnCompile is ignored when invoking directly
scalafix or scalafixAll, as the CLI arguments (if there are any)
take precedence over the default rules. That means for example that
`scalafix --check` is safe to run even though scalafixOnCompile := true,
as there will not be any rewrite triggered by the implicit compilation.
Comment on lines +418 to +421
// passively consume compilation output without triggering compile as it can result in a cyclic dependency
val classpath =
dependencyClasspath.in(config).value.map(_.data.toPath) :+
classDirectory.in(config).value.toPath
Copy link
Collaborator Author

@bjaglin bjaglin Jul 9, 2020

Choose a reason for hiding this comment

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

I have left out copyResources since I don't see why Scalafix would need it but it might be a wrong assumption.

Copy link
Contributor

@olafurpg olafurpg left a comment

Choose a reason for hiding this comment

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

I just tested this locally and it seems to work great! Although I'm personally not a fan of "format/fix on compile", it's pretty clear that a lot of people will love this addition.

I am concerned about redefining compile even when fix on compile is disabled. I wonder how this interacts with other plugins that also override compile, for example sbt-scalafmt 🤔 Can users decide the ordering in which formatting/fixing runs?

To minimize the risk of causing unintended issues, I wonder if we should extract this setting in a separate plugin that users need to explicitly enable? I took a stab at implementing this in the diff here https://github.com/scalacenter/sbt-scalafix/compare/master...olafurpg:scalafixOnCompile?expand=1

I'm not sure which approach is better, just thinking out loud. I am totally fine with the approach in this PR with scalafixOnCompile. You have more experience using this feature so I leave the decision to you

@bjaglin
Copy link
Collaborator Author

bjaglin commented Jul 10, 2020

I can't think of any downside redefining compile when scalafixOnCompile := false, except maybe polluting a tiny bit the inspect tree for compile?

As for what happens when scalafixOnCompile := true, I am not sure the order in which plugins are (explicitly) enabled matter, and therefore if it makes any difference to have a plugin that does not trigger automatically so that users can control ordering with other compile-hijacking plugins. sbt-scalafmt runs before compilation, so that particular plugin is not a problem.

I'd tend to stick to the current implementation for simplicity unless we have factual elements that demonstrate its shortcomings.

@dwijnand, since you are here, your thoughts on this are more than welcome!

@bjaglin
Copy link
Collaborator Author

bjaglin commented Jul 10, 2020

@olafurpg any thoughts on a4ba496 in particular?

@dwijnand
Copy link

dwijnand commented Jul 10, 2020

I think Sébastien said it best (somewhere): if a plugin changes the default build settings/tasks, then it should be opt-in enabled, not auto-trigger from being on the classpath. But it's not a practice that is even documented or even clearly, expressly understood between major sbt plugin authors and sbt maintainers, so it's clearly not strongly observed by plugins in the ecosystem.

So, given that, I don't have any immediate feedback to give (reason why I just +1'd). But, eventually, it might become better to make the "on compile" part opt-in.

I don't even know what the behaviour would be if you did enablePlugins(ScalafmtOnCompilePlugin, ScalafixOnCompilePlugin) (let's say the "before compilation" thing weren't true). It's probably either order of definition or alphabetic ordering, but it could even be something random-looking like the order of hashing something. I know that when sbt's Plugin were re-designed with AutoPlugin the focus was on the plugin dependencies part, in particular for SbtWeb and SbtPlay: before you could get wrong behaviour if you didn't enable correctly, afterwards the order didn't matter and triggers happened as expected. But (as I said) I don't know what the behaviour is for unrelated (technically) plugins like Scalafmt and Scalafix, and I doubt it's documented.

@bjaglin
Copy link
Collaborator Author

bjaglin commented Jul 10, 2020

Thanks a lot for the context @dwijnand !

Since we are pre 1.0 (which maybe wasn't the case for scalafmt that took the scalafmtOnCompile road, so we are stuck to that now), I guess we should go for the separate plugin way from the start.

@olafurpg should I merge this as-is and you open a PR with your extra commits? Or you just push stuff here (I guess collaborators can do that) ?

@olafurpg
Copy link
Contributor

Pinging @sjrd to weigh in on the discussion starting at #140 (review)

Do you think that it's acceptable/idiomatic to redefine the compile task by default as is done in the current PR or do you think it would be preferable to move this logic into a separate off-by-default plugin? I wish there was a more lightweight syntax to enable an off-by-default plugin than creating project/MyPlugin.scala, from the user's perspective it's a lot of additional ceremony compared to ThisBuild / scalafixOnCompile := true.

@olafurpg
Copy link
Contributor

@bjaglin It's a good point that sbt-scalafmt formats before compile so there shouldn't be an integration issue. However, I suppose users would ideally prefer Scalafmt to format after Scalafix when you enable scalafix+scalafmt on compile. Pinging in @poslegm who has worked on the sbt-scalafmt plugin, I wonder if it's worth considering changing the sbt-scalafmt plugin to make it possible to format after Scalafix completes?

@sjrd
Copy link

sjrd commented Jul 10, 2020

@olafurpg: @dwijnand explained my position quite well with regards to modifying existing tasks and settings by default. In general, if you're modifying a task or setting that is not defined by your plugin, there's one big no no: do not make your plugin auto-triggered. Because of how plugins get automatically loaded simply because of being on the classpath (including through transitive dependencies), having auto-triggered plugins that modify existing stuff can cause surprising behaviors, sometimes difficult or impossible to disable.

That said, the way that compile is overridden here is very well done. If the config is such that fix-on-compile is not true (and that being the default), compile basically ends up being overridden by itself, which basically amounts to doing nothing. The rule above about not changing others' settings and tasks in an auto-triggered plugin does not apply.

Another big danger is that any taskDyn or settingDyn worsens a) the output of inspect tree, potentially hiding large swathes of depended on keys and b) the ability for sbt to parallelize task execution, as it needs to know the result of the dynamism before proceeding with the execution of the dynamic aspects. Again, the way that this PR overrides compile means, I believe, that the parallelism of compile will be essentially unaffected. However, scalafixing of course runs sequentially, but that's expected.

So overall, from a technical standpoint of overriding compile, I think this PR is sound and does this the correct way.


Now, you didn't ask my opinion, but I can't comment on a thread like this without giving it anyway: modifying files in a task (either input files or files produced by earlier tasks) is a very bad thing, and you should never do that, ever. The parallelism of sbt means that things can execute concurrently on already created files, causing very bad issues. If you want to modify things, use a command instead (and no, you can't execute a command from a task, not in a sound way anyway).

OK, I said it once, I will now hold my peace about this.

@bjaglin
Copy link
Collaborator Author

bjaglin commented Jul 10, 2020

modifying files in a task (either input files or files produced by earlier tasks) is a very bad thing, and you should never do that, ever

Good point, which I guess goes beyond the scope of this PR as scalafix is already an (input) task mutating input files. However, triggering it from compile would probably expose the problem much more.

Since the scalafix input task's purpose is to transform unmanagedSources output, would it be possible to prevent races by tagging unmanagedSources and scalafix with a custom tag, enforcing at most one concurrent execution by project/config?

@bjaglin
Copy link
Collaborator Author

bjaglin commented Jul 10, 2020

Since the scalafix input task's purpose is to transform unmanagedSources output, would it be possible to prevent races by tagging unmanagedSources and scalafix with a custom tag, enforcing at most one concurrent execution by project/config?

nevermind, that wouldn't prevent tasks depending on unmanagedSources from running concurrently with scalafix....

@bjaglin
Copy link
Collaborator Author

bjaglin commented Jul 10, 2020

I guess we could still use Tags.customLimit to enforce that scalafix does not run concurrently with any other task from the same project/config. To be completely correct, it shouldn't run concurrently with anything at all (Tags.exclusive) since sources might be read across projects/configs, but that would really impact uncached compile invocations on multi-projects builds with scalafixOnCompile := true.

@poslegm
Copy link

poslegm commented Jul 10, 2020

Pinging in @poslegm who has worked on the sbt-scalafmt plugin, I wonder if it's worth considering changing the sbt-scalafmt plugin to make it possible to format after Scalafix completes?

I think it's OK to make changes in sbt-scalafmt because these are projects from the same ecosystem. But I can't figure out yet exactly how to track the scalafix finish from another plugin.

@olafurpg
Copy link
Contributor

Thank you @sjrd for pitching in! That was helpful. With all things considered, I think it's OK to merge this PR as is. I'm not convinced it's worth complicating the installation setup with a separate ScalafixOnCompilePlugin.

I agree with you that modifying files during compile is a bad thing. I have tried to prevent people from running formatting/fixing on compile since 2016 with little luck so far 😅 As for modifying files from any task I agree in principle but I haven't yet figured out how to implement it in practice so far 🤷

@olafurpg
Copy link
Contributor

@poslegm It's a good question. I feel like it might be possible to achieve this by defining a callback hook in sbt-scalafix that sbt-scalafmt can register.

// sbt-scalafix

val scalafixOnCompileHook = taskKey[Seq[File] => Unit]("Callback hook after Scalafix has completed fixing a file")
scalafix := {
  val filesToFix = // .. command like usual
  scalafixOnComipileHook.?.value.foreach(hook => hook(filesToFix))
}

// sbt-scalafmt

TaskKey[Seq[File] => Unit]("scalafixOnCompileHook") := { files => formatFiles(files) }

You can achieve something similar by introducing a direct dependency between the plugins but I don't think that's necessary.

Either way, feel free to ignore these thoughts. This is not blocking this PR, I'm just thinking out loud.

@bjaglin
Copy link
Collaborator Author

bjaglin commented Jul 10, 2020

I feel like it might be possible to achieve this by defining a callback hook in sbt-scalafix that sbt-scalafmt can register.

👍 let's follow-up on implementation details in scalacenter/scalafix#1206

@eed3si9n
Copy link

eed3si9n commented Jul 10, 2020

I'm somewhat wary of compile (most called task in sbt) turning into a dynamic task:

        compile := Def.taskDyn {
          val oldCompile =
            compile.value // evaluated first, before the potential scalafix evaluation
          val runScalafixAfterCompile =
            scalafixOnCompile.value && !scalafixRunExplicitly.value
          if (runScalafixAfterCompile)
            scalafix
              .toTask("")
              .map(_ => oldCompile)
          else Def.task(oldCompile)
        }.value,

When Play needed post processing, Josh and I split compile task into compileIncremental and manipulateBytecode task, which plugins can hook into. I wonder if Scalafix can use that hook? By default:

    manipulateBytecode := compileIncremental.value,

but you could probably do:

Compile / manipulateBytecode := {
  val orig = (Compile / manipulateBytecode).value // respect other plugins
  println("post processing")
  orig
}

and here's a dynamic version of that..

lazy val condition = settingKey[Boolean]("")

condition := true
Compile / manipulateBytecode := (Def.taskDyn {
  val orig = (Compile / manipulateBytecode).value
  if (condition.value) Def.task {
    println("post processing")
    orig
  }
  else Def.task { orig }
}).value

@bjaglin
Copy link
Collaborator Author

bjaglin commented Jul 10, 2020

Thanks for chiming-in @eed3si9n!

I'm somewhat wary of compile (most called task in sbt) turning into a dynamic task:

Could you explain why it's better to redefine (with a dynamic wrapper task ) a subtask of compile rathe than compile itself? I can understand it "blurs out" the task graph a bit less (inspect tree), but is there any runtime impact as well?

It seems to work just fine, but I didn't manage to write a proper motivation in the commit message, thus my question.

@eed3si9n
Copy link

eed3si9n commented Jul 10, 2020

@bjaglin I don't think there will be noticeable performance impact.

One potential benefit of hooking into manipulateBytecode is that it's still in the compile chain of task, which means the rest of the compileTask (https://github.com/sbt/sbt/blob/v1.3.13/main/src/main/scala/sbt/Defaults.scala#L1716-L1735) won't happen on failure, which includes writing Analysis file into the disk. After all you're making compile task fail, so that should correspond with the Analysis.

@olafurpg
Copy link
Contributor

writing Analysis file into the disk. After all you're making compile task fail, so that should correspond with the Analysis.

In the case of Scalafix, I think it would be desirable to persist incremental compile analysis files even in case Scalafix fails. As a user, I would be surprised if a Scalafix linting error would cause sbt to re-compile the Scala sources.

@sjrd
Copy link

sjrd commented Jul 11, 2020

Random thought: have you considered using a triggeredBy(compile) mechanism, instead of modifying compile? This should avoid technical concerns about performance/correctness of modifying compile.

@bjaglin
Copy link
Collaborator Author

bjaglin commented Jul 11, 2020

Random thought: have you considered using a triggeredBy(compile) mechanism, instead of modifying compile?

We have: it wouldn't fail verbosely in case of a lint error.

@bjaglin
Copy link
Collaborator Author

bjaglin commented Jul 13, 2020

I think I will go ahead with this as-is.

Outstanding comments

#140 (comment)

modifying files in a task (either input files or files produced by earlier tasks) is a very bad thing, and you should never do that, ever. The parallelism of sbt means that things can execute concurrently on already created files, causing very bad issues.

I'll follow-up with a PR with a tentative fix for that based on #140 (comment).


#140 (comment)

I'm somewhat wary of compile (most called task in sbt) turning into a dynamic task
When Play needed post processing, Josh and I split compile task into compileIncremental and manipulateBytecode task, which plugins can hook into. I wonder if Scalafix can use that hook?

Given the change that this brings, I feel like it's still better to override compile.


#140 (comment)

I feel like it might be possible to achieve this by defining a callback hook in sbt-scalafix that sbt-scalafmt can register.

Will be handled separately, see scalacenter/scalafix#1206.

@olafurpg
Copy link
Contributor

I agree @bjaglin. LGTM 🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants