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

Add plugin for scalafix #4635

Merged
merged 39 commits into from
Oct 12, 2017
Merged

Add plugin for scalafix #4635

merged 39 commits into from
Oct 12, 2017

Conversation

stuhood
Copy link
Member

@stuhood stuhood commented May 31, 2017

Problem

scalafix supports a bunch of type-aware rewrites of scala code using scalameta, but pants does not currently support it.

Solution

Add support for scalafix in core, with support for semantic rewrites disabled by default. To enable semantic rewrites, it requires an scala compiler plugin, and so enabling it requires extra setup documented here in the README.md for scala.

Result

Running something like:

./pants fmt.scalafix --semantic --rules=RemoveUnusedImports src/scala/org/pantsbuild/zinc/::

would strip unused imports and results in diffs like:

 import sbt.util.{
-  AbstractLogger,
+
   Level,
   Logger
 }

@stuhood
Copy link
Member Author

stuhood commented May 31, 2017

This will need a few more updates (namely a fix for scalacenter/scalafix#176) before landing, so not adding reviewers yet.

@stuhood stuhood force-pushed the stuhood/scalafix branch from c942fb8 to 5ec8ed0 Compare June 10, 2017 01:44
@stuhood
Copy link
Member Author

stuhood commented Jun 21, 2017

This is now reviewable. Will add reviewers once I have green CI.

BUILD.tools Outdated
jar_library(
name = 'scalac-plugin-dep',
jars = [jar(org='org.scalameta', name='scalahost_{}'.format(SCALA_REV), rev='1.8.0')],
)
Copy link
Contributor

Choose a reason for hiding this comment

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

Would it be possible to experiment with pre-release versions of scalameta without forking pants?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes. Tool definitions can be reconfigured on a repo by repo basis.

Copy link
Contributor

Choose a reason for hiding this comment

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

After our recent release, this should now be jars = [jar(org='org.scalameta', name='scalac-semanticdb_{}'.format(SCALA_REV), rev='2.0.0')],.

'src/python/pants/goal:task_registrar',
],
distribution_name='pantsbuild.pants.contrib.scalafix',
description='scalafix support for pants.',
Copy link
Contributor

Choose a reason for hiding this comment

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

@olafurpg What is the appropriate capitalization of "scalafix" here?

Copy link
Contributor

Choose a reason for hiding this comment

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

I use capital S at the start of sentences and lower-case inside sentences, same as we do with scalameta.

args.append('--sourceroot={}'.format(results_dir))
args.append('--classpath={}'.format(':'.join(classpath)))
if self.get_options().config:
args.append('--config={}'.format(self.get_options().config))
Copy link
Contributor

Choose a reason for hiding this comment

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

@olafurpg What does take precedence: --config or --sourceroot/--classpath?

Copy link
Contributor

Choose a reason for hiding this comment

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

def _run(self, target, results_dir, classpath):
# We always operate on copies of the files, so we execute in place.
args = ['--in-place']
args.append('--sourceroot={}'.format(results_dir))
Copy link
Contributor

Choose a reason for hiding this comment

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

--sourceroot should point to the root of the build. Is this what results_dir stands for?

Copy link
Member Author

@stuhood stuhood Jun 22, 2017

Choose a reason for hiding this comment

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

This is when we are consuming a database, rather than when we're creating it. We're executing the rewrites on files that have been cloned elsewhere (into a private result_dir), rather that directly in the repo. The sourceroot at compile time (database creation time) is relative to the root of the repo, but here it is being consumed on separate copies of the files.

This allows for both ScalaFixCheck and ScalaFixFix to be implemented with the same codepaths.

if self.get_options().rewrites:
args.append('--rewrites={}'.format(self.get_options().rewrites))
if self.get_options().level == 'debug':
args.append('--verbose')
Copy link
Contributor

Choose a reason for hiding this comment

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

Scalafix expects (or is supposed to expect) input files to be specified explicitly via the -f option. Is this option set anywhere in the current implementation?

Copy link
Contributor

Choose a reason for hiding this comment

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

-f or --files is optional, unbound trailing arguments are interpreted as files scalafix foo.scala

if self.get_options().rewrites:
args.append('--rewrites={}'.format(self.get_options().rewrites))
if self.get_options().level == 'debug':
args.append('--verbose')
Copy link
Contributor

Choose a reason for hiding this comment

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

Most of scalafix rewrites need a semantic db to be generated for target files. Is it possible / does it make sense to provide sanity checks for that here?

Copy link
Contributor

Choose a reason for hiding this comment

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

can you elaborate?

Copy link
Contributor

@xeno-by xeno-by Jun 22, 2017

Choose a reason for hiding this comment

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

Yes, this was somewhat ambiguous. What I meant to ask is whether it'd make sense to provide additional safeguards on the Pants side. Iirc, scalafix HEAD says "something something please recompile" if it doesn't find semantic dbs, but this message isn't going to help a Pants plugin user who tries to run scalafix without having a scalahost plugin in the first place.

Copy link
Member Author

Choose a reason for hiding this comment

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

I haven't actually seen the error, so not sure how good it is right now... but would love for the error from scalafix to be sufficiently helpful that I don't need to check for existence here.

Copy link
Contributor

Choose a reason for hiding this comment

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

Some sort of error message saying "make sure you set up the scalameta plugin using the blahblah BUILD.tools target or the blahblah option" would be helpful though. And that can't come from scalafix.

Copy link
Contributor

Choose a reason for hiding this comment

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

scalafix will still in some cases silently fail on misconfigured semanticdbs, scalacenter/scalafix#264 This is a bug, scalafix should report helpful error messages when things go wrong and report the appropriate exit code to accompany it. For example, scalafix will exit with code 32 when it encounters a "stale semanticdb" (file contents doesn't match semanticdb contents).


def execute(self):
classpaths = self.context.products.get_data('runtime_classpath')
# NB: This task uses only the literal classpath of each target, so does not need
Copy link
Contributor

Choose a reason for hiding this comment

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

What's literal classpath?

Copy link
Member Author

@stuhood stuhood Jun 22, 2017

Choose a reason for hiding this comment

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

Well... it's a thing that I don't have any other name for, frankly.

"transitive": All transitive dependencies of a compile unit.
"direct"/"strict": Only the directly declared dependencies of a compile unit.
"literal"/???: Only the classfiles for the compile unit, and none of its dependencies.

If you have something else that you call that, happy to rename.

Copy link
Contributor

Choose a reason for hiding this comment

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

Got it. I just did a bit of googling and didn't find a definition, so decided to ask you to better understand what's going on.

Copy link
Contributor

Choose a reason for hiding this comment

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

Something like "uses only the classes directly generated from the sources of the target"?

Copy link
Contributor

@benjyw benjyw left a comment

Choose a reason for hiding this comment

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

Looks good, as far as I can tell (no scalafix expertise here...)

It would be nice to have a README.md explaining how to set this up, including the BUILD.tools and pants.ini changes required.

contrib_plugin(
name='plugin',
dependencies=[
'contrib/node/src/python/pants/contrib/node:plugin',
Copy link
Contributor

Choose a reason for hiding this comment

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

?

Copy link
Member Author

Choose a reason for hiding this comment

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

Derp, thanks.


def execute(self):
classpaths = self.context.products.get_data('runtime_classpath')
# NB: This task uses only the literal classpath of each target, so does not need
Copy link
Contributor

Choose a reason for hiding this comment

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

Something like "uses only the classes directly generated from the sources of the target"?

if self.get_options().rewrites:
args.append('--rewrites={}'.format(self.get_options().rewrites))
if self.get_options().level == 'debug':
args.append('--verbose')
Copy link
Contributor

Choose a reason for hiding this comment

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

Some sort of error message saying "make sure you set up the scalameta plugin using the blahblah BUILD.tools target or the blahblah option" would be helpful though. And that can't come from scalafix.

@stuhood
Copy link
Member Author

stuhood commented Jun 22, 2017

Hah. It looks like (some) of the CI failures are due to duels happening between scalafix and scalafmt, where scalafmt re-writes some files, and then scalafix rewrites them again from a cached copy which reverts the changes. Thinking about how best to address that (hopefully not by disabling caching for the fmt.scalafix task).

@olafurpg
Copy link
Contributor

@stuhood I've considered consolidating the two into a single cli tool. It should be possible to model scalafmt as a syntactic scalafix rewrite and add support to run scalafmt at the end. Do you think something like that might be useful?

@olafurpg
Copy link
Contributor

Heads up @stuhood, I've been polishing the cli lately to make it (hopefully) more intuitive. The main changes relate to how scalafix finds files. In a nutshell, scalafix will no longer infer the .scala files from the --classpath. Instead, you tell scalafix which files to fix and provide a --classpath to tell scalafix where to look for .semanticdb files. This change makes --include unnecessary, since the file path args act now as --include for both syntactic and semantic rewrites.

Copy link
Member

@mateor mateor left a comment

Choose a reason for hiding this comment

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

It would be nice to ship a test with this but I am okay with it as-is. Excited to try this out.

@olafurpg
Copy link
Contributor

I'm hoping to release scalafix v0.5 around next week Tuesday. The v0.5 milestone has a few simple remaining issues https://github.com/scalacenter/scalafix/milestone/3 I have still yet to document the main changes, but they mostly are aimed to increase the quality-of-life for the cli so it might be worth the wait for this PR

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.

scalafix 0.5.0 is just around the corner, I'm in the process of writing the changelog. Please don't hesitate to ask questions if anything is unclear. Most people in the OSS community use sbt-scalafix so there may be missing details about direct cli usage in the docs.

I can say upfront however that the cli still needs more work on error reporting, esp. related to semanticdb loading.

if self.get_options().rewrites:
args.append('--rewrites={}'.format(self.get_options().rewrites))
if self.get_options().level == 'debug':
args.append('--verbose')
Copy link
Contributor

Choose a reason for hiding this comment

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

scalafix will still in some cases silently fail on misconfigured semanticdbs, scalacenter/scalafix#264 This is a bug, scalafix should report helpful error messages when things go wrong and report the appropriate exit code to accompany it. For example, scalafix will exit with code 32 when it encounters a "stale semanticdb" (file contents doesn't match semanticdb contents).

@stuhood
Copy link
Member Author

stuhood commented Aug 14, 2017

We learned a bit more from b6cf3e5 about how formatters should work, so I've started down the path of implementing a shared base class for scalafmt and scalafix: now added here.

The lint/check versions of the tools need to run as a batch with xargs in order for the performance to not be atrocious (as scalafmt now does). Additionally, we need caching for the checks. But the fmt/rewrite versions of the tools can't use invalidation at all, for the reasons mentioned above: our fingerprinting doesn't react well to files changing out from under it.

The final interesting bit that I want to include in this change is that scalafix needs a classpath for some cases: it's become obvious internally that we cannot "always" require a classpath for lint, as it would make it way too slow to require compiling everything before running the checks. So we'll need to add a task-level option that enables or disables passing the classpath to scalafix (similar to 4b34617): that will allow the task to run either with or without a dependency on the user classpath. That way you can install cheaper scalafix checks in lint, and more expensive checks elsewhere.

Unfortunately, I'm out until September 10th, so there won't be any movement from me until at least then.

@olafurpg
Copy link
Contributor

Scalafix 0.5.0-RC3 is out http://www.scala-lang.org/blog/2017/09/11/scalafix-v0.5.html, changelog https://github.com/scalacenter/scalafix/releases/tag/v0.5.0-RC3 I think the api (both library and cli flags) will remain stable for a while now, at least I don't see any breaking changes coming up anytime soon.

Copy link
Contributor

@xeno-by xeno-by left a comment

Choose a reason for hiding this comment

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

Thanks a lot for your work! I've made some change requests based on our recent Scalameta release.

BUILD.tools Outdated
jar_library(
name = 'scalac-plugin-dep',
jars = [jar(org='org.scalameta', name='scalahost_{}'.format(SCALA_REV), rev='1.8.0')],
)
Copy link
Contributor

Choose a reason for hiding this comment

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

After our recent release, this should now be jars = [jar(org='org.scalameta', name='scalac-semanticdb_{}'.format(SCALA_REV), rev='2.0.0')],.

Unlike `scalafmt` (which is included in the default distribution of pants), `scalafix` requires a
compiler plugin, and is thus distributed as a contrib module with extra setup steps.

The [scalameta](http://scalameta.org/) compiler plugin extracts semantic information at compile
Copy link
Contributor

Choose a reason for hiding this comment

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

The [semanticdb](http://scalameta.org/) compiler plugin...

name = 'scalac-plugin-dep',
jars = [jar(org='org.scalameta', name='scalahost_{}'.format(SCALA_REV), rev='1.8.0')],
)
```
Copy link
Contributor

Choose a reason for hiding this comment

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

See the suggested change to scalac-plugin-dep above.

]

scalac_plugins: [
'scalahost',
Copy link
Contributor

Choose a reason for hiding this comment

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

'semanticdb-scalac',


## Usage

Tasks for both the `fmt` and `lint` goals are provided, each with the same set of options.
Copy link
Contributor

Choose a reason for hiding this comment

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

What do you think about calling these goals fix and check in accordance with how these actions are called in Scalafix? https://github.com/scalacenter/scalafix/blob/master/scalafix-core/shared/src/main/scala/scalafix/rule/Rule.scala.

def register_options(cls, register):
super(ScalaFix, cls).register_options(register)
register('--skip', type=bool, fingerprint=True,
help='Skip running scalafix.')
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we need this option?

classpaths = self.context.products.get_data('runtime_classpath')
# NB: While this task uses only the classes directly generated from the sources of each
# target, the content of the generated semantic DBs will be affected by changes to
# dependencies. Thus, we use invalidate_dependents=True.
Copy link
Contributor

Choose a reason for hiding this comment

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

Very good point!

invalidate_dependents=True) as invalidation_check:
for vt in invalidation_check.all_vts:
if not vt.valid:
self.context.log.debug('Fixing {}...'.format(vt.target.address.spec))
Copy link
Contributor

Choose a reason for hiding this comment

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

May I suggest changing the verb here depending on the goal that is being executed?

pants.ini Outdated
]
no_warning_args: [
'-S-nowarn',
]

scalac_plugins: [
'scalahost',
Copy link
Contributor

Choose a reason for hiding this comment

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

'semanticdb-scalac',

JarDependency(org='com.geirsson',
name='scalafmt-cli_2.11',
rev='1.0.0-RC4')
])
Copy link
Contributor

Choose a reason for hiding this comment

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

Looks like an outdated version.

shutil.copy(src, dst)

# Execute.
result = self.runjava(classpath=self.tool_classpath('scalafix'),
Copy link
Contributor

Choose a reason for hiding this comment

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

Am I right in thinking that scalafix is supposed to be bundled with Pants?

@stuhood
Copy link
Member Author

stuhood commented Sep 21, 2017

Alright, I've made a bunch of changes:

  1. scalafmt and scalafix now extend a ScalaRewriteBase class to provide the generic harnessy bits
  2. added a --transitive option on ScalaRewriteBase
  3. added a --semantic flag for scalafix, to toggle whether the user classpath (and thus semanticdbs) are required.

I'm going to apply @xeno-by's feedback with regard to versioning. I also think that I can remove the contrib module and move this into core by changing the default of the --semantic flag to False: that would allow the tasks to be installed by default in a mode that doesn't require the compiler plugins (or compilation at all)... parties interested in enabling semantic rewrites would still need to install the semanticdb-scalac plugin as documented.

@stuhood stuhood changed the title Add contrib plugin for scalafix Add plugin for scalafix Oct 10, 2017
@stuhood stuhood closed this Oct 10, 2017
@stuhood stuhood deleted the stuhood/scalafix branch October 10, 2017 19:04
@stuhood stuhood reopened this Oct 10, 2017
@stuhood
Copy link
Member Author

stuhood commented Oct 12, 2017

Test flakiness. Merging.

@stuhood stuhood merged commit 1889b93 into pantsbuild:master Oct 12, 2017
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.

5 participants