Skip to content

Commit

Permalink
fix: don't break using-directives by an auto-import for .sc in scal…
Browse files Browse the repository at this point in the history
…a-cli (scalameta#4291)

Scala-cli wraps `.sc` files in a similar way as ammonite - puts it in
object so the resulting code in pc is:
```
object main {
/*script*///> using directive
//> using directive

...
}
```
  • Loading branch information
dos65 authored Aug 29, 2022
1 parent 5c092d3 commit 6ab6649
Show file tree
Hide file tree
Showing 5 changed files with 235 additions and 68 deletions.
63 changes: 40 additions & 23 deletions mtags/src/main/scala-2/scala/meta/internal/pc/AutoImports.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package scala.meta.internal.pc

import scala.annotation.tailrec
import scala.reflect.internal.FatalError

import scala.meta.internal.mtags.MtagsEnrichments._
Expand Down Expand Up @@ -60,11 +59,30 @@ trait AutoImports { this: MetalsGlobal =>
)
}

def forScript =
for {
obj <- lastVisitedParentTrees.collectFirst { case mod: ModuleDef =>
mod
def forScript(isAmmonite: Boolean) = {
val startScriptOffest = {
if (isAmmonite)
ScriptFirstImportPosition.ammoniteScStartOffset(text)
else ScriptFirstImportPosition.scalaCliScStartOffset(text)
}

val scriptModuleDefAndPos =
startScriptOffest.flatMap { offset =>
val startPos = pos.withStart(offset).withEnd(offset)
lastVisitedParentTrees
.collectFirst {
case mod: ModuleDef if mod.pos.overlaps(startPos) => mod
}
.map(mod => (mod, offset))
}

val moduleDefAndPos = scriptModuleDefAndPos.orElse(
lastVisitedParentTrees
.collectFirst { case mod: ModuleDef => mod }
.map(mod => (mod, 0))
)
for {
(obj, firstImportOffset) <- moduleDefAndPos
} yield {
val lastImportOpt = obj.impl.body.iterator
.dropWhile {
Expand All @@ -73,34 +91,33 @@ trait AutoImports { this: MetalsGlobal =>
}
.takeWhile(_.isInstanceOf[Import])
.lastOption
val lastImportLine = lastImportOpt

val offset = lastImportOpt
.map(_.pos.focusEnd.line)
.getOrElse(0) // if no previous import, add the new one at the top
.map(pos.source.lineToOffset)
.getOrElse(firstImportOffset)
new AutoImportPosition(
pos.source.lineToOffset(lastImportLine),
offset,
text,
padTop = false
)
}

// Naive way to find the start discounting any first lines that may be
// scala-cli directives.
@tailrec
def findStart(text: String, index: Int): Int = {
if (text.startsWith("//")) {
val newline = text.indexOf("\n")
if (newline != -1)
findStart(text.drop(newline + 1), index + newline + 1)
else index + newline + 1
} else {
index
}
}

def fileStart =
AutoImportPosition(findStart(text, 0), 0, padTop = false)
AutoImportPosition(
ScriptFirstImportPosition.skipUsingDirectivesOffset(text),
0,
padTop = false
)

val path = pos.source.path
val scriptPos =
if (path.endsWith(".sc")) forScript(isAmmonite = false)
else if (path.endsWith(".amm.sc.scala")) forScript(isAmmonite = true)
else None

(if (pos.source.path.endsWith(".sc.scala")) forScript else None)
scriptPos
.orElse(forScalaSource)
.orElse(Some(fileStart))
}
Expand Down
60 changes: 34 additions & 26 deletions mtags/src/main/scala-3/scala/meta/internal/pc/AutoImports.scala
Original file line number Diff line number Diff line change
Expand Up @@ -255,37 +255,28 @@ object AutoImports:
case pkg: PackageDef if !pkg.symbol.isPackageObject => Some(pkg)
case _ => prev

def ammoniteObjectBody(tree: Tree)(using Context): Option[Template] =
def firstObjectBody(tree: Tree)(using Context): Option[Template] =
tree match
case PackageDef(_, stats) =>
stats.flatMap {
case s: PackageDef => ammoniteObjectBody(s)
case s: PackageDef => firstObjectBody(s)
case TypeDef(_, t @ Template(defDef, _, _, _))
if defDef.symbol.showName == "<init>" =>
Some(t)
case _ => None
}.headOption
case _ => None

// Naive way to find the start discounting any first lines that may be
// scala-cli directives.
@tailrec
def findStart(text: String, index: Int): Int =
if text.startsWith("//") then
val newline = text.indexOf("\n")
if newline != -1 then
findStart(text.drop(newline + 1), index + newline + 1)
else index + newline + 1
else index

def forScalaSource: Option[AutoImportPosition] =
lastPackageDef(None, tree).map { pkg =>
val lastImportStatement =
pkg.stats.takeWhile(_.isInstanceOf[Import]).lastOption
val (lineNumber, padTop) = lastImportStatement match
case Some(stm) => (stm.endPos.line + 1, false)
case None if pkg.pid.symbol.isEmptyPackage =>
(pos.source.offsetToLine(findStart(text, 0)), false)
val offset =
ScriptFirstImportPosition.skipUsingDirectivesOffset(text)
(pos.source.offsetToLine(offset), false)
case None =>
val pos = pkg.pid.endPos
val line =
Expand All @@ -297,25 +288,42 @@ object AutoImports:
new AutoImportPosition(offset, text, padTop)
}

def forScript: Option[AutoImportPosition] =
ammoniteObjectBody(tree).map { tmpl =>
def forScript(isAmmonite: Boolean): Option[AutoImportPosition] =
firstObjectBody(tree).map { tmpl =>
val lastImportStatement =
tmpl.body.takeWhile(_.isInstanceOf[Import]).lastOption
val (lineNumber, padTop) = lastImportStatement match
case Some(stm) => (stm.endPos.line + 1, false)
case None => (tmpl.self.srcPos.line, false)
val offset = pos.source.lineToOffset(lineNumber)
new AutoImportPosition(offset, text, padTop)
val offset = lastImportStatement match
case Some(stm) =>
val offset = pos.source.lineToOffset(stm.endPos.line + 1)
offset
case None =>
val scriptOffset =
if isAmmonite then
ScriptFirstImportPosition.ammoniteScStartOffset(text)
else ScriptFirstImportPosition.scalaCliScStartOffset(text)

scriptOffset.getOrElse(
pos.source.lineToOffset(tmpl.self.srcPos.line)
)
new AutoImportPosition(offset, text, false)
}
end forScript

val path = pos.source.path

def fileStart =
AutoImportPosition(findStart(text, 0), 0, padTop = false)

val ammonite =
if path.endsWith(".sc.scala") then forScript else None
ammonite
AutoImportPosition(
ScriptFirstImportPosition.skipUsingDirectivesOffset(text),
0,
padTop = false,
)

val scriptPos =
if path.endsWith(".sc") then forScript(isAmmonite = false)
else if path.endsWith(".amm.sc.scala") then forScript(isAmmonite = true)
else None

scriptPos
.orElse(forScalaSource)
.getOrElse(fileStart)
end autoImportPosition
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package scala.meta.internal.pc

import scala.annotation.tailrec

import scala.meta._

/**
* Used to determine the position for the first import for scala-cli `.scala` and `.sc` files.
* For scala-cli sources we need to skip `//> using` comments.
*
* For `.sc` Ammonite and Scala-Cli wraps the code for such files.
* The following code:
* ```scala
* val a = 1
* ```
* Is trasnformed and passed into PC as:
* ```scala
* ${tool-defauls-imports}
* object ${wrapperObject} {
* /*<${scriptMarker}>*/
* val a = 1 <-- actual code
* }
* ```
* To find the proper position we need to find the object that contains `/*<${scriptMarker}>*/`
*/
object ScriptFirstImportPosition {

val usingDirectives: List[String] = List("// using", "//> using")

def ammoniteScStartOffset(text: String): Option[Int] = {
val it = tokenize(text).iterator
startMarkerOffset(it, "/*<start>*/").map(_ + 1)
}

def scalaCliScStartOffset(text: String): Option[Int] = {
val iterator = tokenize(text).iterator
startMarkerOffset(iterator, "/*<script>*/").map { startOffset =>
val offset =
skipUsingDirectivesOffset(iterator, None)
.getOrElse(startOffset)

offset + 1
}
}

def skipUsingDirectivesOffset(text: String): Int = {
val it = tokenize(text).iterator
if (it.hasNext) {
it.next() match {
case _: Token.BOF =>
skipUsingDirectivesOffset(it, None)
.map(_ + 1)
.getOrElse(0)
case _ => 0
}
} else 0
}

@tailrec
private def startMarkerOffset(
it: Iterator[Token],
comment: String
): Option[Int] = {
if (it.hasNext) {
it.next() match {
case t: Token.Comment =>
if (t.text == comment) Some(t.pos.end)
else startMarkerOffset(it, comment)
case _ => startMarkerOffset(it, comment)
}
} else None
}

@tailrec
private def skipUsingDirectivesOffset(
it: Iterator[Token],
lastOffset: Option[Int]
): Option[Int] = {
if (it.hasNext) {
it.next match {
case t: Token.Comment
if usingDirectives.exists(prefix => t.text.startsWith(prefix)) =>
skipUsingDirectivesOffset(it, Some(t.pos.end))
case t if isWhitespace(t) =>
skipUsingDirectivesOffset(it, lastOffset)
case _ =>
lastOffset
}
} else lastOffset
}

private def tokenize(text: String): Tokens = {
val tokenized = text.tokenize.toOption
tokenized match {
case None => Tokens(Array.empty)
case Some(v) => v
}
}

private def isWhitespace(t: Token): Boolean =
t.is[Token.Space] || t.is[Token.Tab] || t.is[Token.CR] ||
t.is[Token.LF] || t.is[Token.FF] || t.is[Token.LFLF]
}
4 changes: 3 additions & 1 deletion tests/cross/src/main/scala/tests/BaseAutoImportsSuite.scala
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,12 @@ trait BaseAutoImportsSuite extends BaseCodeActionSuite {
original: String,
expected: String,
selection: Int = 0,
filename: String = "A.scala",
compat: Map[String, String] = Map.empty,
)(implicit
loc: Location
): Unit =
checkEditSelection(name, "A.scala", original, expected, selection)
checkEditSelection(name, filename, original, expected, selection, compat)

def checkAmmoniteEdit(
name: TestOptions,
Expand Down
Loading

0 comments on commit 6ab6649

Please sign in to comment.