Skip to content

Commit

Permalink
Add fix command
Browse files Browse the repository at this point in the history
  • Loading branch information
wleczny committed Mar 6, 2023
1 parent 7b9abfd commit f40679b
Show file tree
Hide file tree
Showing 8 changed files with 246 additions and 39 deletions.
63 changes: 45 additions & 18 deletions modules/build/src/main/scala/scala/build/CrossSources.scala
Expand Up @@ -120,18 +120,22 @@ object CrossSources {
}

/** @return
* a CrossSources and Inputs which contains element processed from using directives
* Inputs which contain elements processed from using directives and preprocessed sources
* extracted from given Inputs
*/
def forInputs(
def allInputsAndPreprocessedSources(
inputs: Inputs,
preprocessors: Seq[Preprocessor],
logger: Logger,
suppressDirectivesInMultipleFilesWarning: Option[Boolean],
maybeRecoverOnError: BuildException => Option[BuildException] = e => Some(e)
): Either[BuildException, (CrossSources, Inputs)] = either {

def preprocessSources(elems: Seq[SingleElement])
: Either[BuildException, Seq[PreprocessedSource]] =
): Either[BuildException, (Inputs, Seq[PreprocessedSource])] = either {
def preprocessSources(
elems: Seq[SingleElement],
inputs: Inputs,
preprocessors: Seq[Preprocessor],
logger: Logger,
maybeRecoverOnError: BuildException => Option[BuildException] = e => Some(e)
): Either[BuildException, Seq[PreprocessedSource]] =
elems
.map { elem =>
preprocessors
Expand All @@ -153,7 +157,13 @@ object CrossSources {
.left.map(CompositeBuildException(_))
.map(_.flatten)

val preprocessedInputFromArgs = value(preprocessSources(inputs.flattened()))
val preprocessedInputFromArgs = value(preprocessSources(
inputs.flattened(),
inputs,
preprocessors,
logger,
maybeRecoverOnError
))

val sourcesFromDirectives =
preprocessedInputFromArgs
Expand All @@ -162,12 +172,34 @@ object CrossSources {
.distinct
val inputsElemFromDirectives =
value(resolveInputsFromSources(sourcesFromDirectives, inputs.enableMarkdown))
val preprocessedSourcesFromDirectives = value(preprocessSources(inputsElemFromDirectives))
val allInputs = inputs.add(inputsElemFromDirectives)

val preprocessedSources =
val preprocessedSourcesFromDirectives = value(preprocessSources(
inputsElemFromDirectives,
inputs,
preprocessors,
logger,
maybeRecoverOnError
))

val allInputs = inputs.add(inputsElemFromDirectives)
val allPreprocessedSources =
(preprocessedInputFromArgs ++ preprocessedSourcesFromDirectives).distinct

(allInputs, allPreprocessedSources)
}

/** @return
* a CrossSources and Inputs which contains element processed from using directives
*/
def forInputs(
inputs: Inputs,
preprocessors: Seq[Preprocessor],
logger: Logger,
suppressDirectivesInMultipleFilesWarning: Option[Boolean],
maybeRecoverOnError: BuildException => Option[BuildException] = e => Some(e)
): Either[BuildException, (CrossSources, Inputs)] = either {
val (allInputs, preprocessedSources) =
value(allInputsAndPreprocessedSources(inputs, preprocessors, logger, maybeRecoverOnError))

val preprocessedWithUsingDirs = preprocessedSources.filter(_.directivesPositions.isDefined)
if (
preprocessedWithUsingDirs.length > 1 && !suppressDirectivesInMultipleFilesWarning.getOrElse(
Expand All @@ -182,14 +214,9 @@ object CrossSources {
.foreach { source =>
source.directivesPositions match
case Some(positions) =>
val pos = Seq(
positions.codeDirectives,
positions.specialCommentDirectives,
positions.plainCommentDirectives
).maxBy(p => p.endPos._1)
logger.diagnostic(
s"Using directives detected in multiple files. It is recommended to keep them centralized in the $projectFilePath file.",
positions = Seq(pos)
positions = Seq(positions.scope)
)
case _ => ()
}
Expand Down
Expand Up @@ -31,7 +31,10 @@ case class DirectivesPositions(
codeDirectives: Position.File,
specialCommentDirectives: Position.File,
plainCommentDirectives: Position.File
)
) {
def scope: Position.File =
Seq(codeDirectives, specialCommentDirectives, plainCommentDirectives).maxBy(p => p.endPos._1)
}

object ExtractedDirectives {

Expand Down
Expand Up @@ -38,6 +38,7 @@ class ScalaCliCommands(
directories.Directories,
doc.Doc,
export0.Export,
fix.Fix,
fmt.Fmt,
new HelpCmd(help),
installcompletions.InstallCompletions,
Expand Down
117 changes: 117 additions & 0 deletions modules/cli/src/main/scala/scala/cli/commands/fix/Fix.scala
@@ -0,0 +1,117 @@
package scala.cli.commands.fix

import caseapp.core.RemainingArgs

import java.nio.charset.StandardCharsets

import scala.build.input.ElementsUtils.projectSettingsFiles
import scala.build.input.Inputs
import scala.build.internal.{Constants, CustomCodeWrapper}
import scala.build.preprocessing.PreprocessedSource._
import scala.build.preprocessing.{DirectivesPositions, PreprocessedSource, ScopePath}
import scala.build.{CrossSources, Logger, Position, Sources}
import scala.cli.commands.shared.SharedOptions
import scala.cli.commands.{ScalaCommand, SpecificationLevel}

object Fix extends ScalaCommand[FixOptions] {
override def group = "Main"
override def scalaSpecificationLevel = SpecificationLevel.RESTRICTED
override def sharedOptions(options: FixOptions): Option[SharedOptions] = Some(options.shared)

override def runCommand(options: FixOptions, args: RemainingArgs, logger: Logger): Unit = {
if (options.migrateDirectives.contains(true)) runMigrateDirectives(options, args, logger)
}

private def runMigrateDirectives(
options: FixOptions,
args: RemainingArgs,
logger: Logger
): Unit = {
val buildOptions = buildOptionsOrExit(options)
val inputs = options.shared.inputs(args.remaining, () => Inputs.default()).orExit(logger)
val preprocessors = Sources.defaultPreprocessors(
buildOptions.scriptOptions.codeWrapper.getOrElse(CustomCodeWrapper),
buildOptions.archiveCache,
buildOptions.internal.javaClassNameVersionOpt,
() => buildOptions.javaHome().value.javaCommand
)
val (_, preprocessedSources) = CrossSources.allInputsAndPreprocessedSources(
inputs,
preprocessors,
options.shared.logger
).orExit(logger)

val projectFilePath = inputs.elements.projectSettingsFiles.headOption match
case Some(s) => s.path
case _ => inputs.workspace / Constants.projectFileName
val preprocessedWithUsingDirs = preprocessedSources.filter(_.directivesPositions.isDefined)

preprocessedWithUsingDirs.length match
case 0 => logger.message("No using directives have been found")
case 1 => logger.message("All using directives are already in one file")
case n =>
logger.message(s"Using directives found in $n files. Migrating...")
val directivesSeq = preprocessedWithUsingDirs
.filter(_.scopePath != ScopePath.fromPath(projectFilePath))
.map { source =>
source match
case file: (OnDisk | InMemory) =>
val result = file.directivesPositions match
case Some(positions) =>
val (code, directives) = splitCodeAndDirectives(file, positions)
file match
case f: OnDisk =>
logger.message(s"Deleting directives from ${f.path}...")
os.write.over(f.path, code.getBytes(StandardCharsets.UTF_8))
case f: InMemory =>
logger.message(s"Moving directives from ${f.relPath}...")
Seq(directives)
case _ => Nil
result
case _ => Nil
}
val allFoundDirectives = directivesSeq.fold(Nil)(_ ++ _).mkString("\n")

preprocessedWithUsingDirs.find(_.scopePath == ScopePath.fromPath(projectFilePath)) match
case Some(projectFile) =>
logger.message(s"Found existing project file at $projectFilePath")
val newProjectFileContent = projectFile.directivesPositions match
case Some(positions) =>
val (projectFileCode, projectFileDirectives) =
splitCodeAndDirectives(projectFile, positions)
projectFileDirectives + "\n" + allFoundDirectives + "\n" + projectFileCode
case _ =>
allFoundDirectives + "\n" + os.read(projectFilePath)

logger.message(s"Moving all directives to $projectFilePath")
os.write.over(projectFilePath, newProjectFileContent.getBytes(StandardCharsets.UTF_8))

case _ =>
logger.message(s"Creating project file at $projectFilePath")
logger.message(s"Moving all directives to $projectFilePath")
os.write(
projectFilePath,
allFoundDirectives.getBytes(StandardCharsets.UTF_8),
createFolders = true
)
logger.message(
s"Successfully moved all using directives to the project file: $projectFilePath"
)

def splitCodeAndDirectives(
file: PreprocessedSource,
positions: DirectivesPositions
): (String, String) = {
val fileContent = file match
case f: OnDisk => os.read(f.path)
case f: InMemory => f.code
case _ => ""

val lineStartIndices = Position.Raw.lineStartIndices(fileContent)
val (endLine, endColumn) = (positions.scope.endPos._1, positions.scope.endPos._2)
val endIndex = lineStartIndices(endLine) + endColumn

(fileContent.drop(endIndex), fileContent.take(endIndex))
}
}
}
22 changes: 22 additions & 0 deletions modules/cli/src/main/scala/scala/cli/commands/fix/FixOptions.scala
@@ -0,0 +1,22 @@
package scala.cli.commands.fix

import caseapp.*
import caseapp.core.help.Help

import scala.build.internal.Constants
import scala.cli.commands.shared.{HasSharedOptions, SharedOptions}

// format: off
@HelpMessage("Run fixes for a Scala CLI project")
final case class FixOptions(
@Recurse
shared: SharedOptions = SharedOptions(),
@HelpMessage(s"Move all using directives to the ${Constants.projectFileName} file")
migrateDirectives: Option[Boolean] = None
) extends HasSharedOptions
// format: on

object FixOptions {
implicit lazy val parser: Parser[FixOptions] = Parser.derive
implicit lazy val help: Help[FixOptions] = Help.derive
}
19 changes: 19 additions & 0 deletions website/docs/commands/fix.md
@@ -0,0 +1,19 @@
---
title: Fix
sidebar_position: 20
---

The fix command can be used to perform fixes on a Scala CLI project.

## Migrating directives
It is recommended to keep all `using` directives centralised in one file. Fix command along with the `--migrate-directives` option will migrate all directives of given inputs to the `project.scala` configuration file (if the file does not exist, Scala CLI will create it in the project root directory).

The following command will move all directives inside the current working directory to the `project.scala` file:
```
scala-cli fix --migrate-directives .
```

Please note that this is equivalent to:
```bash ignore
scala-cli migrate-directives .
```

0 comments on commit f40679b

Please sign in to comment.