Skip to content

Commit

Permalink
Rework plugin (#69)
Browse files Browse the repository at this point in the history
  • Loading branch information
regadas authored Nov 21, 2019
1 parent f1ef9df commit 0b1e126
Show file tree
Hide file tree
Showing 11 changed files with 515 additions and 385 deletions.
28 changes: 28 additions & 0 deletions .scalafmt.conf
Original file line number Diff line number Diff line change
@@ -1 +1,29 @@
version = "2.2.2"
maxColumn = 100

binPack.literalArgumentLists = true

continuationIndent {
callSite = 2
defnSite = 2
}

newlines {
afterImplicitKWInVerticalMultiline = true
beforeImplicitKWInVerticalMultiline = true
sometimesBeforeColonInMethodReturnType = true
}

docstrings = JavaDoc

project.git = false

rewrite {
rules = [PreferCurlyFors, RedundantBraces, RedundantParens, SortImports]
redundantBraces.maxLines = 1
}

project.excludeFilters = [
"MultiJoin.scala",
"TupleCoders.scala"
]
2 changes: 1 addition & 1 deletion NOTICE
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
Scio IDEA Plugin
Copyright 2016 Spotify AB
Copyright 2019 Spotify AB
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,6 @@ If there is error level message logged, it will show up in IntelliJ Event Log.

# License

Copyright 2016 Spotify AB.
Copyright 2019 Spotify AB.

Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2017 Spotify AB.
* Copyright 2019 Spotify AB.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down
9 changes: 5 additions & 4 deletions src/main/resources/META-INF/plugin.xml
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
<idea-plugin>
<id>com.spotify.scio-idea</id>
<name>Scio IDEA</name>
<version>0.1.18</version>
<version>0.1.19</version>
<vendor url="https://github.com/spotify/scio-idea-plugin">Spotify</vendor>

<description>IntelliJ IDEA plugin for Scio - https://github.com/spotify/scio</description>

<idea-version since-build="192.0"/>
<idea-version since-build="192.0" />

<depends>com.intellij.modules.java</depends>
<depends>org.intellij.scala</depends>

<extensions defaultExtensionNs="org.intellij.scala">
<syntheticMemberInjector implementation="com.spotify.scio.ScioInjector"/>
<syntheticMemberInjector implementation="com.spotify.scio.AvroTypeInjector" />
<syntheticMemberInjector implementation="com.spotify.scio.BigQueryTypeInjector" />
</extensions>
</idea-plugin>
</idea-plugin>
174 changes: 174 additions & 0 deletions src/main/scala/com/spotify/scio/AnnotationTypeInjector.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/*
* Copyright 2019 Spotify AB.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

package com.spotify.scio

import java.io.File
import java.nio.charset.Charset
import java.nio.file.{Paths, Files => JFiles}

import com.google.common.base.Charsets
import com.google.common.hash.Hashing
import com.google.common.io.Files
import com.intellij.notification.{Notification, NotificationType, Notifications}
import com.intellij.openapi.diagnostic.Logger
import com.intellij.psi.PsiElement
import org.jetbrains.plugins.scala.lang.psi.api.toplevel.typedef.{ScClass, ScTypeDefinition}
import org.jetbrains.plugins.scala.lang.psi.impl.toplevel.typedef.SyntheticMembersInjector

import scala.collection.mutable

object AnnotationTypeInjector {
private val Log = Logger.getInstance(classOf[AnnotationTypeInjector])
private val CaseClassArgs = """case\s+class\s+[^(]+\((.*)\).*""".r
private val TypeArg = """[a-zA-Z0-9]+\s*:\s*[a-zA-Z0-9._]+([\[(](.*?)[)\]]+)?""".r
private val AlertEveryMissedXInvocations = 5

def getApplyPropsSignature(caseClasses: Option[String]): Seq[String] =
getConstructorProps(caseClasses)
.map(_.props)
.getOrElse(Seq.empty)

def getConstructorProps(caseClasses: Option[String]): Option[ConstructorProps] =
caseClasses.collect {
case CaseClassArgs(params) => ConstructorProps(TypeArg.findAllIn(params).toSeq)
}

def getUnapplyReturnTypes(caseClasses: Option[String]): Seq[String] =
getConstructorProps(caseClasses).map(_.types).getOrElse(Seq.empty)

def getTupledMethod(returnClassName: String, caseClasses: Option[String]): String = {
val maybeTupledMethod = getConstructorProps(caseClasses).map {
case cp: ConstructorProps if (2 to 22).contains(cp.types.size) =>
s"def tupled: _root_.scala.Function1[( ${cp.types.mkString(" , ")} ), $returnClassName ] = ???"
case _ =>
""
}

maybeTupledMethod.getOrElse("")
}

final case class ConstructorProps(props: Seq[String]) {
val types: Seq[String] = props.map(_.split(" : ")(1).trim)
}

/**
* Finds BigQuery cache file, must be in sync with Scio implementation, otherwise plugin will
* not be able to find scala files.
*/
private def file(filename: String): Option[File] = {
val sysPropOverride =
Seq("generated.class.cache.directory", "bigquery.class.cache.directory")
.flatMap(sys.props.get)
.headOption
.map(Paths.get(_).resolve(filename))

val resolved = sysPropOverride.getOrElse {
val oldBqPath = Paths
.get(sys.props("java.io.tmpdir"))
.resolve("bigquery-classes")
.resolve(filename)
val newBqPath = Paths
.get(sys.props("java.io.tmpdir"))
.resolve(sys.props("user.name"))
.resolve("bigquery-classes")
.resolve(filename)
val path = Paths
.get(sys.props("java.io.tmpdir"))
.resolve(sys.props("user.name"))
.resolve("generated-classes")
.resolve(filename)

Seq(path, newBqPath, oldBqPath).find(JFiles.exists(_)).getOrElse(path)
}

val file = resolved.toFile
if (file.exists()) {
Log.debug(s"Found $resolved")
Some(file)
} else {
Log.warn(s"missing file: $resolved")
None
}
}

/**
* Computes hash for macro - the hash must be consistent with hash implementation in Scio.
*/
def hash(owner: String, srcFile: String): String =
Hashing
.murmur3_32()
.newHasher()
.putString(owner, Charsets.UTF_8)
.putString(srcFile, Charsets.UTF_8)
.hash()
.toString
}

trait AnnotationTypeInjector extends SyntheticMembersInjector {
import AnnotationTypeInjector._

private[this] val classMissed =
mutable.HashMap.empty[String, Int].withDefaultValue(0)

protected def generatedCaseClasses(source: String, c: ScClass) = {
// For some reason sometimes [[getVirtualFile]] returns null, use Option. I don't know why.
val fileName =
Option(c.asInstanceOf[PsiElement].getContainingFile.getVirtualFile)
// wrap VirtualFile to java.io.File to use OS file separator
.map(vf => new File(vf.getCanonicalPath).getCanonicalPath)

val file = for {
psiElementPath <- fileName
hash <- Some(hash(source, psiElementPath))
cf <- classFile(c, hash)
} yield cf

file.fold(Seq.empty[String]) { f =>
import collection.JavaConverters._
Files
.readLines(f, Charset.defaultCharset())
.asScala
.filter(_.contains("case class"))
}
}

protected def classFile(klass: ScClass, hash: String): Option[java.io.File] = {
val filename = s"${klass.name}-$hash.scala"
file(filename) match {
case f: Some[File] =>
classMissed(filename) = 0
f
case none =>
classMissed(filename) += 1
val errorMessage =
"Scio plugin could not find scala files for code completion. Please (re)compile the project."
if (classMissed(filename) >= AlertEveryMissedXInvocations) {
// reset counter
classMissed(filename) = 0
val notification = new Notification(
"ScioIDEA",
"Scio Plugin",
errorMessage,
NotificationType.ERROR
)
Notifications.Bus.notify(notification)
}
none
}
}
}
110 changes: 110 additions & 0 deletions src/main/scala/com/spotify/scio/AvroTypeInjector.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
* Copyright 2019 Spotify AB.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

package com.spotify.scio

import java.io.File
import java.nio.charset.Charset
import java.nio.file.{Paths, Files => JFiles}

import com.google.common.base.Charsets
import com.google.common.hash.Hashing
import com.google.common.io.Files
import com.intellij.notification.{Notification, NotificationType, Notifications}
import com.intellij.openapi.diagnostic.Logger
import com.intellij.psi.PsiElement
import org.jetbrains.plugins.scala.lang.psi.api.toplevel.typedef.{ScClass, ScTypeDefinition}
import org.jetbrains.plugins.scala.lang.psi.impl.toplevel.typedef.SyntheticMembersInjector

import scala.collection.mutable

object AvroTypeInjector {
private val Log = Logger.getInstance(classOf[AvroTypeInjector])

private val AvroTNamespace = "AvroType"
private val AvroAnnotations = Seq(
s"$AvroTNamespace.fromSchema",
s"$AvroTNamespace.fromPath",
s"$AvroTNamespace.toSchema"
)
private val CaseClassSuper =
"_root_.com.spotify.scio.avro.types.AvroType.HasAvroAnnotation"

private def avroAnnotation(sc: ScClass): Option[String] =
sc.annotations
.map(_.getText)
.find(t => AvroAnnotations.exists(t.contains))
}

final class AvroTypeInjector extends AnnotationTypeInjector {
import AvroTypeInjector._
import AnnotationTypeInjector._

override def needsCompanionObject(source: ScTypeDefinition): Boolean = false

override def injectFunctions(source: ScTypeDefinition): Seq[String] =
source match {
case c: ScClass if avroAnnotation(c).isDefined =>
val parent = c.containingClass.getQualifiedName.init
val caseClasses = generatedCaseClasses(parent, c).find { c =>
c.contains(CaseClassSuper)
}
getApplyPropsSignature(caseClasses).map(v => s"def $v = ???")
case _ => Seq.empty
}

override def injectSupers(source: ScTypeDefinition): Seq[String] =
source match {
case c: ScClass if avroAnnotation(c).isDefined => Seq(CaseClassSuper)
case _ => Seq.empty
}

/**
* Main method of the plugin. Injects syntactic inner members like case classes and companion
* objects, makes IntelliJ happy about BigQuery macros. Assumes macro is enclosed within
* class/object.
*/
override def injectInners(source: ScTypeDefinition): Seq[String] = {
source.extendsBlock.members
.collect {
case c: ScClass if avroAnnotation(c).isDefined =>
val caseClasses =
generatedCaseClasses(source.getQualifiedName.init, c).find { c =>
c.contains(CaseClassSuper)
}
(c, caseClasses)
}
.collect {
case (c, caseClasses) if caseClasses.nonEmpty =>
val tupledMethod = getTupledMethod(c.getName, caseClasses)
val applyPropsSignature =
getApplyPropsSignature(caseClasses).mkString(",")
val unapplyReturnTypes =
getUnapplyReturnTypes(caseClasses).mkString(",")

s"""|object ${c.getName} {
| def apply( $applyPropsSignature ): ${c.getName} = ???
| def unapply(x$$0: ${c.getName}): _root_.scala.Option[($unapplyReturnTypes)] = ???
| def fromGenericRecord: _root_.scala.Function1[_root_.org.apache.avro.generic.GenericRecord, ${c.getName} ] = ???
| def toGenericRecord: _root_.scala.Function1[ ${c.getName}, _root_.org.apache.avro.generic.GenericRecord] = ???
| def schema: _root_.org.apache.avro.Schema = ???
| def toPrettyString(indent: Int = 0): String = ???
| $tupledMethod
|}""".stripMargin
}
}
}
Loading

0 comments on commit 0b1e126

Please sign in to comment.