From 488964905df55c09510b6b68721b0bdb82140e68 Mon Sep 17 00:00:00 2001 From: warcholjakub Date: Wed, 1 Oct 2025 14:37:16 +0200 Subject: [PATCH 1/5] scalafmt: add formatting check to CI, update scalafmt version and rules, remove obsolete bin/scalafmt Adds scalafmt check to CI, updates scalafmt version and rules, and removes the obsolete bin/scalafmt script. --- .github/workflows/test.yml | 3 +++ .scalafmt.conf | 6 +++--- CONTRIBUTING.md | 2 +- bin/scalafmt | 17 ----------------- project/Welcome.scala | 10 +++++++++- project/plugins.sbt | 1 + 6 files changed, 17 insertions(+), 22 deletions(-) delete mode 100755 bin/scalafmt diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6a7731d7c..8847cc52c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,3 +26,6 @@ jobs: - name: Run tests run: | nix-shell --command "yarn install && sbt -DSnippetsContainerTest.mongo=true cachedCiTest" + - name: Check formatting + run: | + nix-shell --command "sbt scalafmtCheckAll" \ No newline at end of file diff --git a/.scalafmt.conf b/.scalafmt.conf index d0fa0b373..c6e1f2c08 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,4 +1,4 @@ -version = 3.8.2 +version = 3.9.10 style = default maxColumn = 120 @@ -8,8 +8,8 @@ fileOverride { "glob:**/metals-runner/src/**" { runner.dialect = scala3 } - "glob:**/scalajvm-3/com.olegych.scastie.api.runtime/**" { - runner.dialect = scala3 + "glob:**/scalajvm-3/org.scastie.runtime/**" { + runner.dialect = scala3 } } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a336db364..42dcc0d85 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,7 +75,7 @@ open `http://localhost:9000` ## Scalafmt -Make sure to run `bin/scalafmt` to format your code. +Make sure to run `sbt scalafmtAll` to format your code. You can install a pre-commit hook with `bin/hooks.sh` diff --git a/bin/scalafmt b/bin/scalafmt deleted file mode 100755 index db464d36a..000000000 --- a/bin/scalafmt +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash - -# set -x - -HERE="`dirname $0`" - -if [ ! -f $HERE/.coursier ]; then - curl -L -o $HERE/.coursier https://git.io/vgvpD - chmod +x $HERE/.coursier -fi - -if [ ! -f $HERE/.scalafmt ]; then - $HERE/.coursier bootstrap com.geirsson:scalafmt-cli_2.12:1.5.1 --main org.scalafmt.cli.Cli -o $HERE/.scalafmt - chmod +x $HERE/.scalafmt -fi - -$HERE/.scalafmt "$@" diff --git a/project/Welcome.scala b/project/Welcome.scala index 3d95d8e8c..5f28ba1d8 100644 --- a/project/Welcome.scala +++ b/project/Welcome.scala @@ -33,7 +33,15 @@ object Welcome { ), UsefulTask( "/testOnly [ClassName] -- -z \"test description\"", - "Run a specific test case in a module." + "Run a specific test case in a module" + ), + UsefulTask( + "scalafmtCheckAll", + "Check that all Scala code is properly formatted" + ), + UsefulTask( + "scalafmtAll", + "Format all Scala code" ) ) } \ No newline at end of file diff --git a/project/plugins.sbt b/project/plugins.sbt index 070f98c48..b20970311 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -6,6 +6,7 @@ addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.11.0") addSbtPlugin("com.eed3si9n" % "sbt-projectmatrix" % "0.9.1") addSbtPlugin("org.scala-js" % "sbt-scalajs" % SbtShared.ScalaJSVersions.current) addSbtPlugin("com.github.reibitto" % "sbt-welcome" % "0.5.0") +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.5") addSbtPlugin("org.olegych" %% "sbt-cached-ci" % "1.0.4") addSbtPlugin("org.scalablytyped.converter" % "sbt-converter" % "1.0.0-beta43") From 5723ae17fa4fc9dc4e2a181a9b8c17a1382db942 Mon Sep 17 00:00:00 2001 From: warcholjakub Date: Wed, 1 Oct 2025 14:46:59 +0200 Subject: [PATCH 2/5] scalafmt: adjust configuration --- .scalafmt.conf | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.scalafmt.conf b/.scalafmt.conf index c6e1f2c08..9afae3741 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -8,7 +8,7 @@ fileOverride { "glob:**/metals-runner/src/**" { runner.dialect = scala3 } - "glob:**/scalajvm-3/org.scastie.runtime/**" { + "glob:**/scalajvm-3/scastie/runtime/**" { runner.dialect = scala3 } } @@ -38,8 +38,9 @@ newlines.topLevelStatements = [before, after] newlines.topLevelStatementsMinBreaks = 2 newlines.implicitParamListModifierForce = [before] -continuationIndent.defnSite = 2 -continuationIndent.extendSite = 2 +indent.defnSite = 2 +indent.extendSite = 2 +indent.ctorSite = 4 rewrite.imports.expand = false rewrite.trailingCommas.style = "never" From 4f7bf50323c530f68d19ea9a1ad5939043b71246 Mon Sep 17 00:00:00 2001 From: warcholjakub Date: Wed, 1 Oct 2025 14:48:30 +0200 Subject: [PATCH 3/5] scalafmt: adjust configuration part 2 --- .scalafmt.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.scalafmt.conf b/.scalafmt.conf index 9afae3741..e0e8d8132 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -25,7 +25,7 @@ project.excludeFilters = [ storage/src/test/resources demo/ ] -align.preset = more +align.preset = some rewrite.rules = [Imports] rewrite.imports.sort = original From 505443d572b777b1b40c87eb3e14958140769498 Mon Sep 17 00:00:00 2001 From: warcholjakub Date: Wed, 1 Oct 2025 14:57:46 +0200 Subject: [PATCH 4/5] scalafmt: apply code formatting --- .../scala/org/scastie/api/ApiModels.scala | 51 +- .../scala/org/scastie/api/CompilerInfo.scala | 2 +- .../scala/org/scastie/api/ConsoleOutput.scala | 12 +- .../main/scala/org/scastie/api/Inputs.scala | 75 ++- .../main/scala/org/scastie/api/Outputs.scala | 14 +- .../scala/org/scastie/api/ProcessOutput.scala | 3 +- .../scala/org/scastie/api/RuntimeCodecs.scala | 2 +- .../scala/org/scastie/api/ScalaTarget.scala | 92 ++-- .../org/scastie/api/ScalaTargetType.scala | 15 +- .../scala/org/scastie/api/ScalaVersions.scala | 19 +- .../scala/org/scastie/api/ServerState.scala | 3 +- .../scala/org/scastie/api/SnippetId.scala | 16 +- .../org/scastie/api/SnippetProgress.scala | 39 +- .../org/scastie/api/StatusProgress.scala | 2 +- .../main/scala/org/scastie/api/TaskId.scala | 2 +- .../org/scastie/balancer/BaseDispatcher.scala | 34 +- .../org/scastie/balancer/DispatchActor.scala | 94 ++-- .../org/scastie/balancer/LoadBalancer.scala | 31 +- .../org/scastie/balancer/ProgressActor.scala | 33 +- .../org/scastie/balancer/SbtDispatcher.scala | 69 ++- .../org/scastie/balancer/SbtServer.scala | 9 +- .../scastie/balancer/ScalaCliDispatcher.scala | 49 +- .../org/scastie/balancer/StatusActor.scala | 37 +- .../balancer/LoadBalancerRecoveryTest.scala | 132 +++-- .../scastie/balancer/LoadBalancerTest.scala | 21 +- .../balancer/LoadBalancerTestUtils.scala | 31 +- .../org/scastie/balancer/TestUtils.scala | 2 + .../scastie/client/AnsiColorFormatter.scala | 18 +- .../scala/org/scastie/client/ClientMain.scala | 96 ++-- .../org/scastie/client/ConsoleState.scala | 1 + .../org/scastie/client/EmbeddedOptions.scala | 162 +++--- .../org/scastie/client/EventStream.scala | 30 +- .../scala/org/scastie/client/Global.scala | 67 ++- .../org/scastie/client/HTMLFormatter.scala | 21 +- .../org/scastie/client/LocalStorage.scala | 4 +- .../scala/org/scastie/client/ModalState.scala | 8 +- .../org/scastie/client/RestApiClient.scala | 64 +-- .../scala/org/scastie/client/Routing.scala | 78 ++- .../scala/org/scastie/client/RoutingADT.scala | 11 +- .../org/scastie/client/ScastieBackend.scala | 483 ++++++++---------- .../org/scastie/client/ScastieState.scala | 64 ++- .../main/scala/org/scastie/client/Views.scala | 1 + .../client/components/BuildSettings.scala | 49 +- .../client/components/ClearButton.scala | 21 +- .../client/components/CodeSnippets.scala | 129 +++-- .../scastie/client/components/Console.scala | 65 ++- .../scastie/client/components/CopyModal.scala | 18 +- .../client/components/DesktopButton.scala | 16 +- .../client/components/DownloadButton.scala | 29 +- .../client/components/EditorTopBar.scala | 144 +++--- .../client/components/EmbeddedOverlay.scala | 19 +- .../client/components/FormatButton.scala | 24 +- .../scastie/client/components/HelpModal.scala | 43 +- .../client/components/LoginModal.scala | 36 +- .../scastie/client/components/MainPanel.scala | 327 ++++++------ .../components/MetalsStatusIndicator.scala | 31 +- .../scastie/client/components/MobileBar.scala | 49 +- .../org/scastie/client/components/Modal.scala | 16 +- .../scastie/client/components/NewButton.scala | 34 +- .../components/PrivacyPolicyModal.scala | 14 +- .../components/PrivacyPolicyPrompt.scala | 41 +- .../client/components/PromptModal.scala | 41 +- .../scastie/client/components/RunButton.scala | 37 +- .../client/components/ScaladexSearch.scala | 404 ++++++++------- .../scastie/client/components/Scastie.scala | 36 +- .../scastie/client/components/SideBar.scala | 136 ++--- .../scastie/client/components/Status.scala | 91 ++-- .../client/components/TargetSelector.scala | 52 +- .../scastie/client/components/TopBar.scala | 128 ++--- .../client/components/VersionSelector.scala | 121 ++--- .../client/components/ViewToggleButton.scala | 31 +- .../client/components/WorksheetButton.scala | 34 +- .../client/components/editor/CodeEditor.scala | 178 ++++--- .../editor/DebouncingCapabilities.scala | 17 +- .../editor/DecorationProvider.scala | 95 ++-- .../client/components/editor/Editor.scala | 23 +- .../components/editor/EditorKeymaps.scala | 16 +- .../components/editor/EditorTextOps.scala | 3 +- .../editor/InteractiveProvider.scala | 82 +-- .../editor/MetalsAutocompletion.scala | 141 +++-- .../components/editor/MetalsClient.scala | 77 +-- .../components/editor/MetalsHover.scala | 46 +- .../components/editor/OnChangeHandler.scala | 6 +- .../components/editor/SimpleEditor.scala | 91 ++-- .../editor/SyntaxHighlightingHandler.scala | 32 +- .../editor/SyntaxHighlightingPlugin.scala | 51 +- .../editor/SyntaxHighlightingTheme.scala | 5 +- .../components/editor/TreesitterParser.scala | 9 +- .../scastie/client/components/package.scala | 105 ++-- .../scala/org/scastie/client/i18n/I18n.scala | 121 +++-- .../org/scastie/client/i18n/Languages.scala | 4 +- .../scala/org/scastie/client/package.scala | 5 +- .../client/scalacli/ScalaCliUtils.scala | 50 +- .../client/scalacli/ScalaVersionUtil.scala | 122 ++--- .../scastie/instrumentation/Instrument.scala | 85 +-- .../instrumentation/InstrumentedInputs.scala | 20 +- .../scastie/instrumentation/LineMapper.scala | 4 +- .../org/scastie/instrumentation/Patch.scala | 17 +- .../instrumentation/RuntimeConstants.scala | 22 +- .../org/scastie/instrumentation/Diff.scala | 39 +- .../instrumentation/InstrumentSpecs.scala | 22 +- .../instrumentation/LineMapperSpecs.scala | 59 ++- .../org/scastie/metals/DTOExtensions.scala | 52 +- .../org/scastie/metals/JavaConverters.scala | 53 +- .../org/scastie/metals/MetalsDispatcher.scala | 81 ++- .../metals/PresentationCompilers.scala | 12 +- .../org/scastie/metals/ScastieMetals.scala | 6 +- .../scastie/metals/ScastieMetalsRoutes.scala | 29 +- .../metals/ScastiePresentationCompiler.scala | 2 +- .../scala/org/scastie/metals/Server.scala | 10 +- .../scastie/metals/MetalsDispatcherTest.scala | 21 +- .../org/scastie/metals/MetalsServerTest.scala | 67 ++- .../scala/org/scastie/metals/TestUtils.scala | 32 +- .../scastie/runtime/api/EscapeString.scala | 5 +- .../scastie/runtime/api/Instrumentation.scala | 7 +- .../scastie/runtime/api/RuntimeError.scala | 26 +- .../scastie/runtime/api/ScalaJsResult.scala | 4 +- .../runtime/InstrumentationRecorder.scala | 4 +- .../scala/scastie/runtime/SharedRuntime.scala | 3 +- .../scalajs/scastie/runtime/DomHook.scala | 7 +- .../scalajs/scastie/runtime/Runtime.scala | 22 +- .../scalajvm-2/scastie/runtime/Runtime.scala | 10 +- .../scalajvm-3/scastie/runtime/Runtime.scala | 9 +- .../scalajvm/scastie/runtime/Runtime.scala | 7 +- .../scala/org/scastie/sbt/FormatActor.scala | 26 +- .../org/scastie/sbt/OutputExtractor.scala | 99 ++-- .../main/scala/org/scastie/sbt/SbtActor.scala | 54 +- .../main/scala/org/scastie/sbt/SbtMain.scala | 27 +- .../scala/org/scastie/sbt/SbtProcess.scala | 238 +++++---- .../org/scastie/sbt/FormatActorTest.scala | 4 +- .../scala/org/scastie/sbt/SbtActorTest.scala | 92 ++-- .../scastie/sbt/plugin/CompilerReporter.scala | 81 ++- .../sbt/plugin/RuntimeErrorLogger.scala | 96 ++-- .../scastie/sbt/plugin/SbtScastiePlugin.scala | 24 +- .../src/main/scala/sbt/ScastieTrapExit.scala | 280 +++++----- .../org/scastie/scalacli/BspClient.scala | 222 ++++---- .../org/scastie/scalacli/ScalaCliActor.scala | 186 ++++--- .../org/scastie/scalacli/ScalaCliMain.scala | 18 +- .../org/scastie/scalacli/ScalaCliRunner.scala | 132 ++--- .../scastie/scalacli/ScalaCliRunnerTest.scala | 285 +++++------ .../org/scastie/server/RestApiServer.scala | 34 +- .../scala/org/scastie/server/ServerMain.scala | 38 +- .../org/scastie/server/oauth2/Github.scala | 27 +- .../server/oauth2/GithubUserSession.scala | 38 +- .../oauth2/InMemoryRefreshTokenStorage.scala | 33 +- .../server/oauth2/UserDirectives.scala | 15 +- .../org/scastie/server/routes/ApiRoutes.scala | 146 +++--- .../server/routes/DownloadRoutes.scala | 36 +- .../server/routes/FrontPageRoutes.scala | 90 ++-- .../scastie/server/routes/OAuth2Routes.scala | 115 ++--- .../server/routes/ProgressRoutes.scala | 22 +- .../scastie/server/routes/ScalaJsRoutes.scala | 65 ++- .../server/routes/ScalaLangRoutes.scala | 20 +- .../scastie/server/routes/StatusRoutes.scala | 73 ++- .../org/scastie/server/routes/package.scala | 61 ++- .../server/utils/NightlyVersionFetcher.scala | 34 +- .../scastie/web/SnippetIdMatcherTests.scala | 60 +-- .../scastie/storage/OldScastieConverter.scala | 91 ++-- .../scastie/storage/SnippetsContainer.scala | 75 +-- .../filesystem/FilesystemContainer.scala | 5 +- .../FilesystemSnippetsContainer.scala | 122 ++--- .../filesystem/FilesystemUsersContainer.scala | 30 +- .../GenericFilesystemContainer.scala | 1 + .../storage/inmemory/InMemoryContainer.scala | 7 +- .../inmemory/InMemorySnippetsContainer.scala | 25 +- .../inmemory/InMemoryUsersContainer.scala | 7 +- .../mongodb/GenericMongoContainer.scala | 3 +- .../storage/mongodb/MongoDBContainer.scala | 21 +- .../mongodb/MongoDBSnippetsContainer.scala | 101 ++-- .../mongodb/MongoDBStoredClasses.scala | 3 +- .../mongodb/MongoDBUsersContainer.scala | 13 +- .../org/scastie/storage/ContainerTest.scala | 84 +-- .../scala/org/scastie/util/Base64UUID.scala | 5 +- .../org/scastie/util/BlockingProcess.scala | 239 ++++----- .../scastie/util/GraphStageForwarder.scala | 4 +- .../util/GraphStageLogicForwarder.scala | 29 +- .../scala/org/scastie/util/ProcessActor.scala | 65 ++- .../org/scastie/util/ReconnectingActor.scala | 21 +- .../main/scala/org/scastie/util/SbtTask.scala | 3 +- .../scala/org/scastie/util/ScalaCliTask.scala | 3 +- .../org/scastie/util/ScastieFileUtil.scala | 4 +- .../org/scastie/util/ProcessActorTest.scala | 20 +- 182 files changed, 4978 insertions(+), 5003 deletions(-) diff --git a/api/src/main/scala/org/scastie/api/ApiModels.scala b/api/src/main/scala/org/scastie/api/ApiModels.scala index 516f91aa0..71debdbff 100644 --- a/api/src/main/scala/org/scastie/api/ApiModels.scala +++ b/api/src/main/scala/org/scastie/api/ApiModels.scala @@ -1,7 +1,7 @@ package org.scastie.api -import io.circe.generic.semiauto._ import io.circe._ +import io.circe.generic.semiauto._ case object RunnerPing case object RunnerPong @@ -37,7 +37,8 @@ case class FormatResponse(result: String) object FetchResult { implicit val fetchResultEncoder: Encoder[FetchResult] = deriveEncoder[FetchResult] implicit val fetchResultDecoder: Decoder[FetchResult] = deriveDecoder[FetchResult] - def create(inputs: BaseInputs, progresses: List[SnippetProgress]) = FetchResult(inputs, progresses.sortBy(p => (p.id, p.ts))) + def create(inputs: BaseInputs, progresses: List[SnippetProgress]) = + FetchResult(inputs, progresses.sortBy(p => (p.id, p.ts))) } case class FetchResult private (inputs: BaseInputs, progresses: List[SnippetProgress]) @@ -56,7 +57,13 @@ object ScalaDependency { implicit val scalaDependencyDecoder: Decoder[ScalaDependency] = deriveDecoder[ScalaDependency] } -case class ScalaDependency(groupId: String, artifact: String, target: ScalaTarget, version: String, isAutoResolve: Boolean = true) { +case class ScalaDependency( + groupId: String, + artifact: String, + target: ScalaTarget, + version: String, + isAutoResolve: Boolean = true +) { def matches(sd: ScalaDependency): Boolean = sd.groupId == this.groupId && sd.artifact == this.artifact def renderSbt: String = { @@ -68,6 +75,7 @@ case class ScalaDependency(groupId: String, artifact: String, target: ScalaTarge val resolveSymbol = if (isAutoResolve) "::" else ":" s"//> using dep $groupId$resolveSymbol$artifact:$version" } + } case class ScastieMetalsOptions(dependencies: Set[ScalaDependency], scalaTarget: ScalaTarget, code: String) @@ -87,7 +95,6 @@ case class NoResult(msg: String) extends FailureType case class PresentationCompilerFailure(msg: String) extends FailureType case class InvalidScalaVersion(msg: String) extends FailureType - object FailureType { implicit val failureTypeEncoder: Encoder[FailureType] = deriveEncoder[FailureType] implicit val noResultDecoder: Decoder[FailureType] = deriveDecoder[FailureType] @@ -99,8 +106,10 @@ object NoResult { } object PresentationCompilerFailure { - implicit val presentationCompilerFailureEncoder: Encoder[PresentationCompilerFailure] = deriveEncoder[PresentationCompilerFailure] - implicit val presentationCompilerFailureDecoder: Decoder[PresentationCompilerFailure] = deriveDecoder[PresentationCompilerFailure] + implicit val presentationCompilerFailureEncoder: Encoder[PresentationCompilerFailure] = + deriveEncoder[PresentationCompilerFailure] + implicit val presentationCompilerFailureDecoder: Decoder[PresentationCompilerFailure] = + deriveDecoder[PresentationCompilerFailure] } object ScastieOffsetParams { @@ -128,16 +137,15 @@ case class EditRange(startLine: Int, startChar: Int, endLine: Int, endChar: Int) case class ScalaCompletionList(items: Set[CompletionItemDTO], isIncomplete: Boolean) case class CompletionItemDTO( - label: String, - detail: String, - tpe: String, - order: Option[Int], - instructions: InsertInstructions, - additionalInsertInstructions: List[AdditionalInsertInstructions], - symbol: Option[String] + label: String, + detail: String, + tpe: String, + order: Option[Int], + instructions: InsertInstructions, + additionalInsertInstructions: List[AdditionalInsertInstructions], + symbol: Option[String] ) - case class HoverDTO(from: Int, to: Int, content: String) case class CompletionsDTO(items: Set[CompletionItemDTO]) @@ -153,8 +161,10 @@ object InsertInstructions { } object AdditionalInsertInstructions { - implicit val additionalInsertInstructionsEncoder: Encoder[AdditionalInsertInstructions] = deriveEncoder[AdditionalInsertInstructions] - implicit val additionalInsertInstructionsDecoder: Decoder[AdditionalInsertInstructions] = deriveDecoder[AdditionalInsertInstructions] + implicit val additionalInsertInstructionsEncoder: Encoder[AdditionalInsertInstructions] = + deriveEncoder[AdditionalInsertInstructions] + implicit val additionalInsertInstructionsDecoder: Decoder[AdditionalInsertInstructions] = + deriveDecoder[AdditionalInsertInstructions] } object ScalaCompletionList { @@ -199,19 +209,22 @@ case class KeepAlive(msg: String = "") extends AnyVal sealed trait EditorMode case object Default extends EditorMode -case object Vim extends EditorMode -case object Emacs extends EditorMode +case object Vim extends EditorMode +case object Emacs extends EditorMode object EditorMode { + implicit val editorModeFormat: Encoder[EditorMode] = Encoder.encodeString.contramap { case Default => "Default" case Vim => "Vim" case Emacs => "Emacs" } + implicit val editorModeDecoder: Decoder[EditorMode] = Decoder.decodeString.emap { case "Default" => Right(Default) case "Vim" => Right(Vim) case "Emacs" => Right(Emacs) case other => Left(s"Unknown EditorMode: $other") } -} \ No newline at end of file + +} diff --git a/api/src/main/scala/org/scastie/api/CompilerInfo.scala b/api/src/main/scala/org/scastie/api/CompilerInfo.scala index 2fd2cccdf..1d8f38334 100644 --- a/api/src/main/scala/org/scastie/api/CompilerInfo.scala +++ b/api/src/main/scala/org/scastie/api/CompilerInfo.scala @@ -1,7 +1,7 @@ package org.scastie.api -import io.circe.generic.semiauto._ import io.circe._ +import io.circe.generic.semiauto._ object Severity { implicit val severityEncoder: Encoder[Severity] = deriveEncoder[Severity] diff --git a/api/src/main/scala/org/scastie/api/ConsoleOutput.scala b/api/src/main/scala/org/scastie/api/ConsoleOutput.scala index 4985e4c80..6db9c5bbf 100644 --- a/api/src/main/scala/org/scastie/api/ConsoleOutput.scala +++ b/api/src/main/scala/org/scastie/api/ConsoleOutput.scala @@ -1,7 +1,7 @@ package org.scastie.api -import io.circe.generic.semiauto._ import io.circe._ +import io.circe.generic.semiauto._ sealed trait ConsoleOutput { def show: String @@ -27,9 +27,9 @@ object ConsoleOutput { implicit val consoleOutputEncoder: Encoder[ConsoleOutput] = deriveEncoder[ConsoleOutput] implicit val consoleOutputDecoder: Decoder[ConsoleOutput] = deriveDecoder[ConsoleOutput] - def systemOutput(target: ScalaTarget)(output: ProcessOutput): ConsoleOutput = - target.targetType match { - case ScalaTargetType.ScalaCli => ScalaCliOutput(output) - case _ => SbtOutput(output) - } + def systemOutput(target: ScalaTarget)(output: ProcessOutput): ConsoleOutput = target.targetType match { + case ScalaTargetType.ScalaCli => ScalaCliOutput(output) + case _ => SbtOutput(output) + } + } diff --git a/api/src/main/scala/org/scastie/api/Inputs.scala b/api/src/main/scala/org/scastie/api/Inputs.scala index 5a48a0baa..0c3bfa541 100644 --- a/api/src/main/scala/org/scastie/api/Inputs.scala +++ b/api/src/main/scala/org/scastie/api/Inputs.scala @@ -1,9 +1,8 @@ package org.scastie.api -import io.circe.generic.semiauto._ import io.circe._ +import io.circe.generic.semiauto._ import org.scastie.buildinfo.BuildInfo - import System.{lineSeparator => nl} sealed trait BaseInputs { @@ -16,7 +15,7 @@ sealed trait BaseInputs { def markAsCopied: BaseInputs = { this match { - case s: SbtInputs => s.copy(isShowingInUserProfile = false, forked = None) + case s: SbtInputs => s.copy(isShowingInUserProfile = false, forked = None) case s: ScalaCliInputs => s.copy(isShowingInUserProfile = false, forked = None) } } @@ -33,23 +32,24 @@ sealed trait BaseInputs { isShowingInUserProfile: Boolean = this.isShowingInUserProfile, code: String = this.code, libraries: Set[ScalaDependency] = this.libraries, - forked: Option[SnippetId] = this.forked, + forked: Option[SnippetId] = this.forked ): BaseInputs = this match { case scalaCliInputs: ScalaCliInputs => scalaCliInputs.copy( - isWorksheetMode = isWorksheetMode, - isShowingInUserProfile = isShowingInUserProfile, - code = code, - forked = forked, - libraries = libraries - ) + isWorksheetMode = isWorksheetMode, + isShowingInUserProfile = isShowingInUserProfile, + code = code, + forked = forked, + libraries = libraries + ) case sbtInputs: SbtInputs => sbtInputs.copy( - isWorksheetMode = isWorksheetMode, - isShowingInUserProfile = isShowingInUserProfile, - code = code, - libraries = libraries, - forked = forked - ) + isWorksheetMode = isWorksheetMode, + isShowingInUserProfile = isShowingInUserProfile, + code = code, + libraries = libraries, + forked = forked + ) } + } object BaseInputs { @@ -92,15 +92,13 @@ object SbtInputs { } case class ScalaCliInputs( - isWorksheetMode: Boolean, - code: String, - target: ScalaCli, - isShowingInUserProfile: Boolean, - forked: Option[SnippetId] = None, - libraries: Set[ScalaDependency] = Set.empty -) extends BaseInputs { - -} + isWorksheetMode: Boolean, + code: String, + target: ScalaCli, + isShowingInUserProfile: Boolean, + forked: Option[SnippetId] = None, + libraries: Set[ScalaDependency] = Set.empty +) extends BaseInputs {} object ScalaCliInputs { val defaultCode = """List("Hello", "World").mkString("", ", ", "!")""" @@ -169,9 +167,11 @@ case class SbtInputs( lazy val isDefault: Boolean = copy(code = "").withSavedConfig == SbtInputs.default.copy(code = "").withSavedConfig - def modifyConfig(inputs: SbtInputs => SbtInputs): SbtInputs = inputs(this).copy(sbtConfigSaved = None, sbtPluginsConfigSaved = None) + def modifyConfig(inputs: SbtInputs => SbtInputs): SbtInputs = + inputs(this).copy(sbtConfigSaved = None, sbtPluginsConfigSaved = None) - def withSavedConfig: SbtInputs = copy(sbtConfigSaved = Some(sbtConfigGenerated), sbtPluginsConfigSaved = Some(sbtPluginsConfigGenerated)) + def withSavedConfig: SbtInputs = + copy(sbtConfigSaved = Some(sbtConfigGenerated), sbtPluginsConfigSaved = Some(sbtPluginsConfigGenerated)) def clearDependencies: SbtInputs = { modifyConfig { @@ -204,9 +204,8 @@ case class SbtInputs( val newScalaDependency = scalaDependency.copy(version = version) val newLibraries = libraries.filterNot(_.matches(scalaDependency)) + newScalaDependency val newLibrariesFromList = librariesFromList.collect { - case (l, p) if l.matches(scalaDependency) => - newScalaDependency -> p - case (l, p) => l -> p + case (l, p) if l.matches(scalaDependency) => newScalaDependency -> p + case (l, p) => l -> p } modifyConfig { _.copy( @@ -216,8 +215,7 @@ case class SbtInputs( } } - lazy val sbtConfig: String = - mapToConfig(sbtConfigGenerated, sbtConfigExtra) + lazy val sbtConfig: String = mapToConfig(sbtConfigGenerated, sbtConfigExtra) lazy val sbtConfigGenerated: String = sbtConfigSaved.getOrElse { val targetConfig = target.sbtConfig @@ -226,8 +224,7 @@ case class SbtInputs( if (target.hasWorksheetMode) Some(target.runtimeDependency) else None - val allLibraries = - optionalTargetDependency.map(libraries + _).getOrElse(libraries) + val allLibraries = optionalTargetDependency.map(libraries + _).getOrElse(libraries) val librariesConfig = if (allLibraries.isEmpty) "" @@ -249,26 +246,24 @@ case class SbtInputs( mapToConfig(targetConfig, librariesConfig) } - lazy val sbtPluginsConfig: String = - mapToConfig(sbtPluginsConfigGenerated, sbtPluginsConfigExtra) + lazy val sbtPluginsConfig: String = mapToConfig(sbtPluginsConfigGenerated, sbtPluginsConfigExtra) lazy val sbtPluginsConfigGenerated: String = sbtPluginsConfigSaved.getOrElse { sbtPluginsConfig0(withSbtScastie = true) } - private def mapToConfig(parts: String*): String = - parts.filter(_.nonEmpty).mkString("\n") + private def mapToConfig(parts: String*): String = parts.filter(_.nonEmpty).mkString("\n") private def sbtPluginsConfig0(withSbtScastie: Boolean): String = { val targetConfig = target.sbtPluginsConfig val sbtScastie = - if (withSbtScastie) - s"""addSbtPlugin("org.scastie" % "sbt-scastie" % "${BuildInfo.versionRuntime}")""" + if (withSbtScastie) s"""addSbtPlugin("org.scastie" % "sbt-scastie" % "${BuildInfo.versionRuntime}")""" else "" mapToConfig(targetConfig, sbtScastie) } + } object EditInputs { diff --git a/api/src/main/scala/org/scastie/api/Outputs.scala b/api/src/main/scala/org/scastie/api/Outputs.scala index eb54e4dfb..d9a037f9d 100644 --- a/api/src/main/scala/org/scastie/api/Outputs.scala +++ b/api/src/main/scala/org/scastie/api/Outputs.scala @@ -1,7 +1,7 @@ package org.scastie.api -import io.circe.generic.semiauto._ import io.circe._ +import io.circe.generic.semiauto._ import org.scastie.runtime.api._ import RuntimeCodecs._ @@ -25,7 +25,9 @@ object Outputs { runtimeError = None, sbtError = false ) + } + case class Outputs( consoleOutputs: Vector[ConsoleOutput], compilationInfos: Set[Problem], @@ -36,9 +38,9 @@ case class Outputs( def console: String = consoleOutputs.mkString("\n") - def isClearable: Boolean = - consoleOutputs.nonEmpty || - compilationInfos.nonEmpty || - instrumentations.nonEmpty || - runtimeError.isDefined + def isClearable: Boolean = consoleOutputs.nonEmpty || + compilationInfos.nonEmpty || + instrumentations.nonEmpty || + runtimeError.isDefined + } diff --git a/api/src/main/scala/org/scastie/api/ProcessOutput.scala b/api/src/main/scala/org/scastie/api/ProcessOutput.scala index 93f6ffc9d..0f60605d0 100644 --- a/api/src/main/scala/org/scastie/api/ProcessOutput.scala +++ b/api/src/main/scala/org/scastie/api/ProcessOutput.scala @@ -1,9 +1,10 @@ package org.scastie.api -import io.circe.generic.semiauto._ import io.circe._ +import io.circe.generic.semiauto._ sealed trait ProcessOutputType + object ProcessOutputType { case object StdOut extends ProcessOutputType case object StdErr extends ProcessOutputType diff --git a/api/src/main/scala/org/scastie/api/RuntimeCodecs.scala b/api/src/main/scala/org/scastie/api/RuntimeCodecs.scala index 36c505c98..50bd37f10 100644 --- a/api/src/main/scala/org/scastie/api/RuntimeCodecs.scala +++ b/api/src/main/scala/org/scastie/api/RuntimeCodecs.scala @@ -1,7 +1,7 @@ package org.scastie.api -import io.circe.generic.semiauto._ import io.circe._ +import io.circe.generic.semiauto._ import org.scastie.runtime.api._ object RuntimeCodecs { diff --git a/api/src/main/scala/org/scastie/api/ScalaTarget.scala b/api/src/main/scala/org/scastie/api/ScalaTarget.scala index 3487f9455..1154ce6a4 100644 --- a/api/src/main/scala/org/scastie/api/ScalaTarget.scala +++ b/api/src/main/scala/org/scastie/api/ScalaTarget.scala @@ -1,8 +1,8 @@ package org.scastie.api -import org.scastie.buildinfo.BuildInfo -import io.circe.generic.semiauto._ import io.circe._ +import io.circe.generic.semiauto._ +import org.scastie.buildinfo.BuildInfo sealed trait ScalaTarget { val targetType: ScalaTargetType @@ -17,7 +17,6 @@ sealed trait ScalaTarget { def runtimeDependency: ScalaDependency = ScalaDependency(BuildInfo.organization, BuildInfo.runtimeProjectName, this, BuildInfo.versionRuntime) - } sealed trait SbtScalaTarget extends ScalaTarget { @@ -30,8 +29,7 @@ sealed trait SbtScalaTarget extends ScalaTarget { def renderDependency(lib: ScalaDependency): String - protected def sbtConfigScalaVersion: String = - s"""scalaVersion := "$scalaVersion"""" + protected def sbtConfigScalaVersion: String = s"""scalaVersion := "$scalaVersion"""" protected def renderSbtDouble(lib: ScalaDependency): String = { import lib._ @@ -47,13 +45,14 @@ sealed trait SbtScalaTarget extends ScalaTarget { def withScalaVersion(newVersion: String): SbtScalaTarget = { this match { - case Scala2(scalaVersion) => Scala2(newVersion) - case Typelevel(scalaVersion) => Typelevel(newVersion) - case Js(scalaVersion, scalaJsVersion) => Js(newVersion, scalaJsVersion) + case Scala2(scalaVersion) => Scala2(newVersion) + case Typelevel(scalaVersion) => Typelevel(newVersion) + case Js(scalaVersion, scalaJsVersion) => Js(newVersion, scalaJsVersion) case Native(scalaVersion, scalaNativeVersion) => Native(newVersion, scalaNativeVersion) - case Scala3(scalaVersion) => Scala3(newVersion) + case Scala3(scalaVersion) => Scala3(newVersion) } } + } object Scala2 { @@ -66,15 +65,14 @@ case class Scala2(scalaVersion: String) extends SbtScalaTarget { val targetType: ScalaTargetType = ScalaTargetType.Scala2 - def scaladexRequest: Map[String, String] = - Map("target" -> "JVM", "scalaVersion" -> binaryScalaVersion) + def scaladexRequest: Map[String, String] = Map("target" -> "JVM", "scalaVersion" -> binaryScalaVersion) def renderDependency(lib: ScalaDependency): String = renderSbtDouble(lib) def sbtConfig: String = { val base = sbtConfigScalaVersion + "\n" + SbtScalaTarget.hktScalacOptions(scalaVersion) if (scalaVersion.startsWith("2.13") || scalaVersion.startsWith("2.12")) - base + "\n" + "scalacOptions += \"-Ydelambdafy:inline\"" //workaround https://github.com/scala/bug/issues/10782 + base + "\n" + "scalacOptions += \"-Ydelambdafy:inline\"" // workaround https://github.com/scala/bug/issues/10782 else base } @@ -97,8 +95,7 @@ case class Typelevel(scalaVersion: String) extends SbtScalaTarget { val targetType: ScalaTargetType = ScalaTargetType.Typelevel - def scaladexRequest: Map[String, String] = - Map("target" -> "JVM", "scalaVersion" -> scalaVersion) + def scaladexRequest: Map[String, String] = Map("target" -> "JVM", "scalaVersion" -> scalaVersion) def renderDependency(lib: ScalaDependency): String = renderSbtDouble(lib) @@ -138,8 +135,7 @@ case class Js(scalaVersion: String, scalaJsVersion: String) extends SbtScalaTarg else scalaJsVersion.split('.').head) ) - def renderDependency(lib: ScalaDependency): String = - s"${renderSbtCross(lib)} cross CrossVersion.for3Use2_13" + def renderDependency(lib: ScalaDependency): String = s"${renderSbtCross(lib)} cross CrossVersion.for3Use2_13" def sbtConfig: String = { s"""|$sbtConfigScalaVersion @@ -149,13 +145,13 @@ case class Js(scalaVersion: String, scalaJsVersion: String) extends SbtScalaTarg |scalacOptions += { | val from = (LocalRootProject / baseDirectory).value.toURI.toString | val to = "${Js.sourceUUID}/" - | "-${if (scalaVersion.startsWith("3")) "scalajs-mapSourceURI" else "P:scalajs:mapSourceURI"}:" + from + "->" + to + | "-${if (scalaVersion.startsWith("3")) "scalajs-mapSourceURI" + else "P:scalajs:mapSourceURI"}:" + from + "->" + to |}""".stripMargin } - def sbtPluginsConfig: String = - s"""addSbtPlugin("org.scala-js" % "sbt-scalajs" % "$scalaJsVersion")""" + "\n" + - (if (!scalaVersion.startsWith("3")) SbtScalaTarget.partialUnificationSbtPlugin else "") + def sbtPluginsConfig: String = s"""addSbtPlugin("org.scala-js" % "sbt-scalajs" % "$scalaJsVersion")""" + "\n" + + (if (!scalaVersion.startsWith("3")) SbtScalaTarget.partialUnificationSbtPlugin else "") def sbtRunCommand(worksheetMode: Boolean): String = "fastOptJS" @@ -170,42 +166,36 @@ object Native { implicit val nativeDecoder: Decoder[Native] = deriveDecoder[Native] } - case class Native(scalaVersion: String, scalaNativeVersion: String) extends SbtScalaTarget { val targetType: ScalaTargetType = ScalaTargetType.Native - def scaladexRequest: Map[String, String] = - Map( - "target" -> "NATIVE", - "scalaVersion" -> binaryScalaVersion, - "scalaNativeVersion" -> scalaNativeVersion - ) + def scaladexRequest: Map[String, String] = Map( + "target" -> "NATIVE", + "scalaVersion" -> binaryScalaVersion, + "scalaNativeVersion" -> scalaNativeVersion + ) - def renderDependency(lib: ScalaDependency): String = - renderSbtCross(lib) + def renderDependency(lib: ScalaDependency): String = renderSbtCross(lib) def sbtConfig: String = sbtConfigScalaVersion - def sbtPluginsConfig: String = - s"""addSbtPlugin("org.scala-native" % "sbt-scala-native" % "$scalaNativeVersion")""" + def sbtPluginsConfig: String = s"""addSbtPlugin("org.scala-native" % "sbt-scala-native" % "$scalaNativeVersion")""" def sbtRunCommand(worksheetMode: Boolean): String = if (worksheetMode) "fgRunMain Main" else "fgRun" def isJVMTarget: Boolean = false - override def toString: String = - s"Scala-Native $scalaVersion $scalaNativeVersion" + override def toString: String = s"Scala-Native $scalaVersion $scalaNativeVersion" } object Scala3 { def default: Scala3 = Scala3(BuildInfo.stableNext) - def defaultCode: String = - """|// You can find more examples here: - |// https://github.com/lampepfl/dotty-example-project - |println("Hi Scala 3!") - |""".stripMargin + def defaultCode: String = """|// You can find more examples here: + |// https://github.com/lampepfl/dotty-example-project + |println("Hi Scala 3!") + |""".stripMargin implicit val scala3Encoder: Encoder[Scala3] = deriveEncoder[Scala3] implicit val scala3Decoder: Decoder[Scala3] = deriveDecoder[Scala3] @@ -215,13 +205,11 @@ case class Scala3(scalaVersion: String) extends SbtScalaTarget { val targetType: ScalaTargetType = ScalaTargetType.Scala3 - def scaladexRequest: Map[String, String] = - Map("target" -> "JVM", "scalaVersion" -> binaryScalaVersion) + def scaladexRequest: Map[String, String] = Map("target" -> "JVM", "scalaVersion" -> binaryScalaVersion) def renderDependency(lib: ScalaDependency): String = { if (lib == runtimeDependency) renderSbtDouble(lib) - else if (lib.target.binaryScalaVersion.startsWith("2.13")) - s"${renderSbtDouble(lib)} cross CrossVersion.for3Use2_13" + else if (lib.target.binaryScalaVersion.startsWith("2.13")) s"${renderSbtDouble(lib)} cross CrossVersion.for3Use2_13" else renderSbtDouble(lib) } @@ -233,17 +221,15 @@ case class Scala3(scalaVersion: String) extends SbtScalaTarget { def isJVMTarget: Boolean = true - override def toString: String = - s"Scala $scalaVersion" + override def toString: String = s"Scala $scalaVersion" } - - object SbtScalaTarget { implicit val sbtScalaTargetEncoder: Encoder[SbtScalaTarget] = deriveEncoder[SbtScalaTarget] implicit val sbtScalaTargetDecoder: Decoder[SbtScalaTarget] = deriveDecoder[SbtScalaTarget] def partialUnificationSbtPlugin = """addSbtPlugin("org.lyranthe.sbt" % "partial-unification" % "1.1.2")""" + def hktScalacOptions(scalaVersion: String) = { val (kpOrg, kpVersion, kpCross) = if (scalaVersion == "2.13.0-M5") ("org.spire-math", "0.9.9", "binary") @@ -260,6 +246,7 @@ object SbtScalaTarget { |addCompilerPlugin("${kpOrg}" %% "kind-projector" % "${kpVersion}" cross CrossVersion.${kpCross}) |$paradise""".stripMargin } + } object ScalaCli { @@ -268,13 +255,13 @@ object ScalaCli { def default: ScalaCli = ScalaCli(BuildInfo.stableNext) - def defaultCode: String = - """|// Hello! - |// Scastie is compatible with Scala CLI! You can use - |// directives: https://scala-cli.virtuslab.org/docs/guides/using-directives/ - | - |println("Hi Scala CLI <3") + def defaultCode: String = """|// Hello! + |// Scastie is compatible with Scala CLI! You can use + |// directives: https://scala-cli.virtuslab.org/docs/guides/using-directives/ + | + |println("Hi Scala CLI <3") """.stripMargin + } case class ScalaCli(scalaVersion: String) extends ScalaTarget { @@ -287,4 +274,3 @@ object ScalaTarget { implicit val scalaTargetEncoder: Encoder[ScalaTarget] = deriveEncoder[ScalaTarget] implicit val scalaTargetDecoder: Decoder[ScalaTarget] = deriveDecoder[ScalaTarget] } - diff --git a/api/src/main/scala/org/scastie/api/ScalaTargetType.scala b/api/src/main/scala/org/scastie/api/ScalaTargetType.scala index 97366a0f6..001a38238 100644 --- a/api/src/main/scala/org/scastie/api/ScalaTargetType.scala +++ b/api/src/main/scala/org/scastie/api/ScalaTargetType.scala @@ -1,19 +1,19 @@ package org.scastie.api -import io.circe.generic.semiauto._ import io.circe._ - +import io.circe.generic.semiauto._ import org.scastie.api sealed trait ScalaTargetType { + def defaultScalaTarget: ScalaTarget = { this match { - case ScalaTargetType.Scala2 => api.Scala2.default - case ScalaTargetType.JS => api.Js.default - case ScalaTargetType.Native => api.Native.default + case ScalaTargetType.Scala2 => api.Scala2.default + case ScalaTargetType.JS => api.Js.default + case ScalaTargetType.Native => api.Native.default case ScalaTargetType.Typelevel => api.Typelevel.default - case ScalaTargetType.Scala3 => api.Scala3.default - case ScalaTargetType.ScalaCli => api.ScalaCli.default + case ScalaTargetType.Scala3 => api.Scala3.default + case ScalaTargetType.ScalaCli => api.ScalaCli.default } } @@ -35,7 +35,6 @@ object ScalaTargetType { } } - case object Scala2 extends ScalaTargetType case object Scala3 extends ScalaTargetType case object JS extends ScalaTargetType diff --git a/api/src/main/scala/org/scastie/api/ScalaVersions.scala b/api/src/main/scala/org/scastie/api/ScalaVersions.scala index 6e1d5e678..2071392f6 100644 --- a/api/src/main/scala/org/scastie/api/ScalaVersions.scala +++ b/api/src/main/scala/org/scastie/api/ScalaVersions.scala @@ -3,19 +3,20 @@ package org.scastie.api import org.scastie.buildinfo.BuildInfo object ScalaVersions { + def suggestedScalaVersions(tpe: ScalaTargetType): List[String] = { val versions = tpe match { case ScalaTargetType.Scala3 => List(BuildInfo.stableLTS, BuildInfo.stableNext) - case ScalaTargetType.JS => List(BuildInfo.stableLTS, BuildInfo.stableNext, BuildInfo.latest213, BuildInfo.latest212) - case _ => List(BuildInfo.latest213, BuildInfo.latest212) + case ScalaTargetType.JS => + List(BuildInfo.stableLTS, BuildInfo.stableNext, BuildInfo.latest213, BuildInfo.latest212) + case _ => List(BuildInfo.latest213, BuildInfo.latest212) } versions.distinct } def allVersions(tpe: ScalaTargetType): List[String] = { val versions = tpe match { - case ScalaTargetType.Scala3 => - List( + case ScalaTargetType.Scala3 => List( BuildInfo.latestNext, BuildInfo.stableNext, BuildInfo.latestLTS, @@ -51,10 +52,9 @@ object ScalaVersions { "3.0.1", "3.0.0" ) - case ScalaTargetType.JS => - allVersions(ScalaTargetType.Scala3) ++ allVersions(ScalaTargetType.Scala2).filter(v => v.startsWith("2.12") || v.startsWith("2.13")) - case _ => - List( + case ScalaTargetType.JS => allVersions(ScalaTargetType.Scala3) ++ allVersions(ScalaTargetType.Scala2) + .filter(v => v.startsWith("2.12") || v.startsWith("2.13")) + case _ => List( BuildInfo.latest213, "2.13.15", "2.13.14", @@ -115,6 +115,5 @@ object ScalaVersions { versions.distinct } - def find(tpe: ScalaTargetType, sv: String): String = - allVersions(tpe).find(_.startsWith(sv)).getOrElse(sv) + def find(tpe: ScalaTargetType, sv: String): String = allVersions(tpe).find(_.startsWith(sv)).getOrElse(sv) } diff --git a/api/src/main/scala/org/scastie/api/ServerState.scala b/api/src/main/scala/org/scastie/api/ServerState.scala index 5ea229baf..9ee3609c1 100644 --- a/api/src/main/scala/org/scastie/api/ServerState.scala +++ b/api/src/main/scala/org/scastie/api/ServerState.scala @@ -1,13 +1,14 @@ package org.scastie.api -import io.circe.generic.semiauto._ import io.circe._ +import io.circe.generic.semiauto._ sealed trait ServerState { def isReady: Boolean } object ServerState { + case object Unknown extends ServerState { override def toString: String = "Unknown" def isReady: Boolean = true diff --git a/api/src/main/scala/org/scastie/api/SnippetId.scala b/api/src/main/scala/org/scastie/api/SnippetId.scala index 1be720af7..96cbb83fb 100644 --- a/api/src/main/scala/org/scastie/api/SnippetId.scala +++ b/api/src/main/scala/org/scastie/api/SnippetId.scala @@ -1,7 +1,7 @@ -package org.scastie.api +package org.scastie.api -import io.circe.generic.semiauto._ import io.circe._ +import io.circe.generic.semiauto._ object User { // low tech solution @@ -28,11 +28,11 @@ object SnippetId { } case class SnippetId(base64UUID: String, user: Option[SnippetUserPart]) { + def isOwnedBy(user2: Option[User]): Boolean = { (user, user2) match { - case (Some(SnippetUserPart(snippetLogin, _)), Some(User(userLogin, _, _))) => - snippetLogin == userLogin - case _ => false + case (Some(SnippetUserPart(snippetLogin, _)), Some(User(userLogin, _, _))) => snippetLogin == userLogin + case _ => false } } @@ -40,9 +40,8 @@ case class SnippetId(base64UUID: String, user: Option[SnippetUserPart]) { def url: String = { this match { - case SnippetId(uuid, None) => uuid - case SnippetId(uuid, Some(SnippetUserPart(login, update))) => - s"$login/$uuid/$update" + case SnippetId(uuid, None) => uuid + case SnippetId(uuid, Some(SnippetUserPart(login, update))) => s"$login/$uuid/$update" } } @@ -50,4 +49,5 @@ case class SnippetId(base64UUID: String, user: Option[SnippetUserPart]) { val middle = url s"/api/${Shared.scalaJsHttpPathPrefix}/$middle/$end" } + } diff --git a/api/src/main/scala/org/scastie/api/SnippetProgress.scala b/api/src/main/scala/org/scastie/api/SnippetProgress.scala index 4c1de28eb..e04644ad1 100644 --- a/api/src/main/scala/org/scastie/api/SnippetProgress.scala +++ b/api/src/main/scala/org/scastie/api/SnippetProgress.scala @@ -1,29 +1,29 @@ package org.scastie.api -import io.circe.generic.semiauto._ import io.circe._ +import io.circe.generic.semiauto._ import io.circe.syntax._ import org.scastie.runtime.api._ import RuntimeCodecs._ object SnippetProgress { - def default: SnippetProgress = - SnippetProgress( - ts = None, - id = None, - snippetId = None, - userOutput = None, - buildOutput = None, - compilationInfos = Nil, - instrumentations = Nil, - runtimeError = None, - scalaJsContent = None, - scalaJsSourceMapContent = None, - isDone = true, - isTimeout = false, - isSbtError = false, - isForcedProgramMode = false - ) + + def default: SnippetProgress = SnippetProgress( + ts = None, + id = None, + snippetId = None, + userOutput = None, + buildOutput = None, + compilationInfos = Nil, + instrumentations = Nil, + runtimeError = None, + scalaJsContent = None, + scalaJsSourceMapContent = None, + isDone = true, + isTimeout = false, + isSbtError = false, + isForcedProgramMode = false + ) implicit val snippetProgressEncoder: Encoder[SnippetProgress] = deriveEncoder[SnippetProgress] implicit val snippetProgressDecoder: Decoder[SnippetProgress] = deriveDecoder[SnippetProgress] @@ -45,7 +45,8 @@ case class SnippetProgress( isSbtError: Boolean, isForcedProgramMode: Boolean ) { - def isFailure: Boolean = isTimeout || isSbtError || runtimeError.nonEmpty || compilationInfos.exists(_.severity == Error) + def isFailure: Boolean = + isTimeout || isSbtError || runtimeError.nonEmpty || compilationInfos.exists(_.severity == Error) override def toString: String = this.asJson.spaces2 } diff --git a/api/src/main/scala/org/scastie/api/StatusProgress.scala b/api/src/main/scala/org/scastie/api/StatusProgress.scala index dd8d45a19..9d3bfad72 100644 --- a/api/src/main/scala/org/scastie/api/StatusProgress.scala +++ b/api/src/main/scala/org/scastie/api/StatusProgress.scala @@ -1,7 +1,7 @@ package org.scastie.api -import io.circe.generic.semiauto._ import io.circe._ +import io.circe.generic.semiauto._ case class SbtRunnerState(config: SbtInputs, tasks: Vector[TaskId], sbtState: ServerState) diff --git a/api/src/main/scala/org/scastie/api/TaskId.scala b/api/src/main/scala/org/scastie/api/TaskId.scala index b2b4f1300..c7b0723c3 100644 --- a/api/src/main/scala/org/scastie/api/TaskId.scala +++ b/api/src/main/scala/org/scastie/api/TaskId.scala @@ -1,7 +1,7 @@ package org.scastie.api -import io.circe.generic.semiauto._ import io.circe._ +import io.circe.generic.semiauto._ object TaskId { implicit val taskIdEncoder: Encoder[TaskId] = deriveEncoder[TaskId] diff --git a/balancer/src/main/scala/org/scastie/balancer/BaseDispatcher.scala b/balancer/src/main/scala/org/scastie/balancer/BaseDispatcher.scala index 5fb9c0498..dffcf193a 100644 --- a/balancer/src/main/scala/org/scastie/balancer/BaseDispatcher.scala +++ b/balancer/src/main/scala/org/scastie/balancer/BaseDispatcher.scala @@ -1,17 +1,18 @@ package org.scastie.balancer -import com.typesafe.config.Config -import akka.actor.ActorSelection -import org.scastie.api.ActorConnected -import akka.actor.ActorLogging +import scala.collection.concurrent.TrieMap +import scala.concurrent.duration._ +import scala.concurrent.Future + import akka.actor.Actor +import akka.actor.ActorLogging import akka.actor.ActorRef -import scala.concurrent.Future +import akka.actor.ActorSelection import akka.pattern.ask import akka.util.Timeout -import scala.concurrent.duration._ +import com.typesafe.config.Config +import org.scastie.api.ActorConnected import org.scastie.api.RunnerPing -import scala.collection.concurrent.TrieMap abstract class BaseDispatcher[R, S](config: Config) extends Actor with ActorLogging { case class SocketAddress(host: String, port: Int) @@ -26,7 +27,8 @@ abstract class BaseDispatcher[R, S](config: Config) extends Actor with ActorLogg val host = config.getString(s"remote-$key-hostname") val portStart = config.getInt(s"remote-$key-ports-start") val portSize = config.getInt(s"remote-$key-ports-size") - val result = (0 until portSize).map(_ + portStart) + val result = (0 until portSize) + .map(_ + portStart) .map(port => { val addr = SocketAddress(host, port) (addr, getRemoteActorPath(runnerName, addr, actorName)) @@ -51,23 +53,25 @@ abstract class BaseDispatcher[R, S](config: Config) extends Actor with ActorLogg key: String, runnerName: String, actorName: String - ): TrieMap[SocketAddress, ActorSelection] = { - getRemoteActorsPath(key, runnerName, actorName).map { - case (address, url) => (address, connectRunner(url)) - } + ): TrieMap[SocketAddress, ActorSelection] = { + getRemoteActorsPath(key, runnerName, actorName).map { case (address, url) => + (address, connectRunner(url)) } + } def ping(servers: List[ActorSelection]): Future[List[Boolean]] = { implicit val timeout: Timeout = Timeout(10.seconds) val futures = servers.map { s => - (s ? RunnerPing).map { _ => + (s ? RunnerPing) + .map { _ => log.info(s"pinged $s") true - }.recover { e => + } + .recover { e => log.error(e, s"could not ping $s") false } - } + } Future.sequence(futures) } diff --git a/balancer/src/main/scala/org/scastie/balancer/DispatchActor.scala b/balancer/src/main/scala/org/scastie/balancer/DispatchActor.scala index 6ead770c4..ee454f00a 100644 --- a/balancer/src/main/scala/org/scastie/balancer/DispatchActor.scala +++ b/balancer/src/main/scala/org/scastie/balancer/DispatchActor.scala @@ -1,29 +1,29 @@ package org.scastie.balancer +import java.nio.file.Paths +import java.time.Instant +import java.util.concurrent.Executors +import scala.concurrent._ +import scala.concurrent.duration._ + import akka.actor.Actor import akka.actor.ActorLogging import akka.actor.ActorRef import akka.actor.ActorSelection import akka.actor.OneForOneStrategy -import akka.actor.SupervisorStrategy import akka.actor.Props +import akka.actor.SupervisorStrategy import akka.event import akka.pattern.ask import akka.remote.DisassociatedEvent import akka.util.Timeout +import com.typesafe.config.ConfigFactory import org.scastie.api._ import org.scastie.storage._ import org.scastie.storage.filesystem._ import org.scastie.storage.inmemory._ import org.scastie.storage.mongodb._ import org.scastie.util._ -import com.typesafe.config.ConfigFactory - -import java.nio.file.Paths -import java.time.Instant -import java.util.concurrent.Executors -import scala.concurrent._ -import scala.concurrent.duration._ case class Address(host: String, port: Int) case class SbtConfig(config: String) @@ -62,19 +62,18 @@ case class Done(progress: SnippetProgress, retries: Int) case object Ping /** - * This Actor creates and takes care of two dispatchers: SbtDispatcher and ScalaCliDispatcher. - * It will receive every request and forward to the proper dispatcher every request. + * This Actor creates and takes care of two dispatchers: SbtDispatcher and ScalaCliDispatcher. It will receive every + * request and forward to the proper dispatcher every request. * * @param progressActor * @param statusActor */ class DispatchActor(progressActor: ActorRef, statusActor: ActorRef) // extends PersistentActor with AtLeastOnceDelivery - extends Actor - with ActorLogging { + extends Actor + with ActorLogging { - private val config = - ConfigFactory.load().getConfig("org.scastie.balancer") + private val config = ConfigFactory.load().getConfig("org.scastie.balancer") // Dispatchers val sbtDispatcher: ActorRef = context.actorOf( @@ -87,10 +86,9 @@ class DispatchActor(progressActor: ActorRef, statusActor: ActorRef) "ScalaCliDispatcher" ) - override def supervisorStrategy: SupervisorStrategy = OneForOneStrategy() { - case e => - log.error(e, "failure") - SupervisorStrategy.resume + override def supervisorStrategy: SupervisorStrategy = OneForOneStrategy() { case e => + log.error(e, "failure") + SupervisorStrategy.resume } import context._ @@ -112,34 +110,32 @@ class DispatchActor(progressActor: ActorRef, statusActor: ActorRef) val containerType = config.getString("snippets-storage") - private val container = - containerType match { - case "memory" => new InMemoryContainer() - case "mongo" => new MongoDBContainer()(ExecutionContext.fromExecutor(Executors.newWorkStealingPool())) - case "mongo-local" => new MongoDBContainer(defaultConfig = false)(ExecutionContext.fromExecutor(Executors.newWorkStealingPool())) - case "files" => new FilesystemContainer( + private val container = containerType match { + case "memory" => new InMemoryContainer() + case "mongo" => new MongoDBContainer()(ExecutionContext.fromExecutor(Executors.newWorkStealingPool())) + case "mongo-local" => + new MongoDBContainer(defaultConfig = false)(ExecutionContext.fromExecutor(Executors.newWorkStealingPool())) + case "files" => new FilesystemContainer( Paths.get(config.getString("snippets-dir")), Paths.get(config.getString("old-snippets-dir")) )(ExecutionContext.fromExecutorService(Executors.newCachedThreadPool())) - case _ => - println("fallback to in-memory container") - new InMemoryContainer - } + case _ => + println("fallback to in-memory container") + new InMemoryContainer + } - def run(inputsWithIpAndUser: InputsWithIpAndUser, snippetId: SnippetId) = - self ! Run(inputsWithIpAndUser, snippetId) + def run(inputsWithIpAndUser: InputsWithIpAndUser, snippetId: SnippetId) = self ! Run(inputsWithIpAndUser, snippetId) private def logError[T](f: Future[T]) = { - f.recover { - case e => log.error(e, "failed future") + f.recover { case e => + log.error(e, "failed future") } } def receive: Receive = event.LoggingReceive(event.Logging.InfoLevel) { case RunnerPong => () - case format: FormatRequest => - sbtDispatcher.tell(format, sender()) + case format: FormatRequest => sbtDispatcher.tell(format, sender()) case x @ RunSnippet(inputsWithIpAndUser) => log.info(s"starting ${x}") @@ -163,14 +159,11 @@ class DispatchActor(progressActor: ActorRef, statusActor: ActorRef) val sender = this.sender() logError(container.update(snippetId, inputsWithIpAndUser.inputs).map { updatedSnippetId => sender ! updatedSnippetId - updatedSnippetId.foreach( - snippetIdU => run(inputsWithIpAndUser, snippetIdU) - ) + updatedSnippetId.foreach(snippetIdU => run(inputsWithIpAndUser, snippetIdU)) }) case ForkSnippet(snippetId, inputsWithIpAndUser) => - val InputsWithIpAndUser(inputs, UserTrace(_, user)) = - inputsWithIpAndUser + val InputsWithIpAndUser(inputs, UserTrace(_, user)) = inputsWithIpAndUser val sender = this.sender() logError( container @@ -227,19 +220,17 @@ class DispatchActor(progressActor: ActorRef, statusActor: ActorRef) case x @ ReceiveStatus(requester) => sbtDispatcher.tell(x, sender()) - case statusProgress: StatusProgress => - statusActor ! statusProgress + case statusProgress: StatusProgress => statusActor ! statusProgress case progress: SnippetProgress => val sender = this.sender() - logError( - container.appendOutput(progress) - .recover { - case e => - log.error(e, s"failed to save $progress from $sender") - e + container + .appendOutput(progress) + .recover { case e => + log.error(e, s"failed to save $progress from $sender") + e } .map(sender ! _) ) @@ -258,15 +249,10 @@ class DispatchActor(progressActor: ActorRef, statusActor: ActorRef) case ping: Ping.type => implicit val timeout: Timeout = Timeout(10.seconds) val seq = Future.sequence( - List(scliDispatcher, sbtDispatcher).map { - s => (s ? Ping).map(_ => - log.info(s"Pinged ${s}") - ).recover(_ => - log.info(s"Failed to ping ${s}") - ) + List(scliDispatcher, sbtDispatcher).map { s => + (s ? Ping).map(_ => log.info(s"Pinged ${s}")).recover(_ => log.info(s"Failed to ping ${s}")) } ) } - } diff --git a/balancer/src/main/scala/org/scastie/balancer/LoadBalancer.scala b/balancer/src/main/scala/org/scastie/balancer/LoadBalancer.scala index 279cff1df..6859257a2 100644 --- a/balancer/src/main/scala/org/scastie/balancer/LoadBalancer.scala +++ b/balancer/src/main/scala/org/scastie/balancer/LoadBalancer.scala @@ -1,23 +1,25 @@ package org.scastie.balancer -import java.time.Instant import java.time.temporal.ChronoUnit +import java.time.Instant +import scala.util.Random import org.scastie.api._ import org.slf4j.LoggerFactory -import scala.util.Random - case class Ip(v: String) case class Task[T <: BaseInputs](config: T, ip: Ip, taskId: TaskId, ts: Instant) case class TaskHistory(data: Vector[Task[SbtInputs]], maxSize: Int) { + def add(task: Task[SbtInputs]): TaskHistory = { val cappedData = if (data.length < maxSize) data else data.drop(1) copy(data = cappedData :+ task) } + } + case class LoadBalancer[R, S <: ServerState](servers: Vector[SbtServer[R, S]]) { private val log = LoggerFactory.getLogger(getClass) @@ -41,8 +43,7 @@ case class LoadBalancer[R, S <: ServerState](servers: Vector[SbtServer[R, S]]) { def add(task: Task[SbtInputs]): Option[(SbtServer[R, S], LoadBalancer[R, S])] = { log.info("Task added: {}", task.taskId) - val (availableServers, unavailableServers) = - servers.partition(_.state.isReady) + val (availableServers, unavailableServers) = servers.partition(_.state.isReady) def lastTenMinutes(v: Vector[Task[SbtInputs]]) = v.filter(_.ts.isAfter(Instant.now.minus(10, ChronoUnit.MINUTES))) def lastWithIp(v: Vector[Task[SbtInputs]]) = lastTenMinutes(v.filter(_.ip == task.ip)).lastOption @@ -50,12 +51,18 @@ case class LoadBalancer[R, S <: ServerState](servers: Vector[SbtServer[R, S]]) { if (availableServers.nonEmpty) { val selectedServer = availableServers.maxBy { s => ( - s.mailbox.length < 3, //allow reload if server gets busy - !s.currentConfig.needsReload(task.config), //pick those without need for reload - -s.mailbox.length, //then those least busy - lastTenMinutes(s.mailbox ++ s.history.data).exists(!_.config.needsReload(task.config)), //then those which use(d) this config - lastWithIp(s.mailbox).orElse(lastWithIp(s.history.data)).map(_.ts.toEpochMilli), //then one most recently used by this ip, if any - s.mailbox.lastOption.orElse(s.history.data.lastOption).map(-_.ts.toEpochMilli).getOrElse(0L) //then one least recently used + s.mailbox.length < 3, // allow reload if server gets busy + !s.currentConfig.needsReload(task.config), // pick those without need for reload + -s.mailbox.length, // then those least busy + lastTenMinutes(s.mailbox ++ s.history.data) + .exists(!_.config.needsReload(task.config)), // then those which use(d) this config + lastWithIp(s.mailbox) + .orElse(lastWithIp(s.history.data)) + .map(_.ts.toEpochMilli), // then one most recently used by this ip, if any + s.mailbox.lastOption + .orElse(s.history.data.lastOption) + .map(-_.ts.toEpochMilli) + .getOrElse(0L) // then one least recently used ) } val updatedServers = availableServers.map(old => if (old.id == selectedServer.id) old.add(task) else old) @@ -63,7 +70,7 @@ case class LoadBalancer[R, S <: ServerState](servers: Vector[SbtServer[R, S]]) { ( selectedServer, copy( - servers = updatedServers ++ unavailableServers, + servers = updatedServers ++ unavailableServers // history = updatedHistory ) ) diff --git a/balancer/src/main/scala/org/scastie/balancer/ProgressActor.scala b/balancer/src/main/scala/org/scastie/balancer/ProgressActor.scala index 85b187c1c..194b4940b 100644 --- a/balancer/src/main/scala/org/scastie/balancer/ProgressActor.scala +++ b/balancer/src/main/scala/org/scastie/balancer/ProgressActor.scala @@ -1,14 +1,14 @@ package org.scastie.balancer -import akka.NotUsed +import scala.collection.mutable.{Map => MMap, Queue => MQueue} +import scala.concurrent.duration.DurationLong + import akka.actor.{Actor, ActorRef} import akka.stream.scaladsl.Source +import akka.NotUsed import org.scastie.api._ import org.scastie.util.GraphStageForwarder -import scala.collection.mutable.{Map => MMap, Queue => MQueue} -import scala.concurrent.duration.DurationLong - case class SubscribeProgress(snippetId: SnippetId) private case class Cleanup(snippetId: SnippetId) @@ -24,15 +24,16 @@ class ProgressActor extends Actor { val (source, _) = getOrCreateNewSubscriberInfo(snippetId, self) sender() ! source - case snippetProgress: SnippetProgress => - snippetProgress.snippetId.foreach { snippetId => + case snippetProgress: SnippetProgress => snippetProgress.snippetId.foreach { snippetId => getOrCreateNewSubscriberInfo(snippetId, self) queuedMessages.getOrElseUpdate(snippetId, MQueue()).enqueue(snippetProgress) sendQueuedMessages(snippetId, self) } case (snippedId: SnippetId, graphStageForwarderActor: ActorRef) => - subscribers.get(snippedId).foreach(s => subscribers.update(snippedId, s.copy(_2 = Some(graphStageForwarderActor)))) + subscribers + .get(snippedId) + .foreach(s => subscribers.update(snippedId, s.copy(_2 = Some(graphStageForwarderActor)))) sendQueuedMessages(snippedId, self) case Cleanup(snippetId) => @@ -47,13 +48,13 @@ class ProgressActor extends Actor { ) } - private def sendQueuedMessages(snippetId: SnippetId, self: ActorRef): Unit = - for { - messageQueue <- queuedMessages.get(snippetId).toSeq - (_, Some(graphStageForwarderActor)) <- subscribers.get(snippetId).toSeq - message <- messageQueue.dequeueAll(_ => true) - } yield { - graphStageForwarderActor ! message - if (message.isDone) context.system.scheduler.scheduleOnce(3.seconds, self, Cleanup(snippetId))(context.dispatcher) - } + private def sendQueuedMessages(snippetId: SnippetId, self: ActorRef): Unit = for { + messageQueue <- queuedMessages.get(snippetId).toSeq + (_, Some(graphStageForwarderActor)) <- subscribers.get(snippetId).toSeq + message <- messageQueue.dequeueAll(_ => true) + } yield { + graphStageForwarderActor ! message + if (message.isDone) context.system.scheduler.scheduleOnce(3.seconds, self, Cleanup(snippetId))(context.dispatcher) + } + } diff --git a/balancer/src/main/scala/org/scastie/balancer/SbtDispatcher.scala b/balancer/src/main/scala/org/scastie/balancer/SbtDispatcher.scala index ab30e0194..cebe17646 100644 --- a/balancer/src/main/scala/org/scastie/balancer/SbtDispatcher.scala +++ b/balancer/src/main/scala/org/scastie/balancer/SbtDispatcher.scala @@ -1,39 +1,39 @@ package org.scastie.balancer -import akka.event +import java.time.Instant +import java.util.concurrent.atomic.AtomicReference +import java.util.concurrent.Executors +import scala.concurrent._ +import scala.concurrent.duration._ +import scala.concurrent.Future + import akka.actor.Actor +import akka.actor.ActorLogging import akka.actor.ActorRef +import akka.actor.ActorSelection +import akka.actor.ActorSystem +import akka.actor.Address +import akka.event +import akka.pattern.ask +import akka.remote.DisassociatedEvent +import akka.util.Timeout import com.typesafe.config.Config -import akka.actor.ActorLogging import com.typesafe.config.ConfigFactory -import akka.actor.ActorSelection import org.scastie.api._ import org.scastie.util._ -import scala.concurrent.Future -import akka.remote.DisassociatedEvent -import java.time.Instant -import scala.concurrent._ -import akka.pattern.ask - -import scala.concurrent.duration._ -import java.util.concurrent.Executors -import akka.actor.Address -import akka.actor.ActorSystem -import akka.util.Timeout -import java.util.concurrent.atomic.AtomicReference class SbtDispatcher(config: Config, progressActor: ActorRef, statusActor: ActorRef) - extends BaseDispatcher[ActorSelection, ServerState](config) with Actor { + extends BaseDispatcher[ActorSelection, ServerState](config) + with Actor { private val parent = context.parent val remoteSbtSelections = getRemoteServers("sbt", "SbtRunner", "SbtActor") val balancer: AtomicReference[SbtBalancer] = { - val sbtServers = remoteSbtSelections.to(Vector).map { - case (_, ref) => - val state: ServerState = ServerState.Unknown - SbtServer(ref, SbtInputs.default, state) + val sbtServers = remoteSbtSelections.to(Vector).map { case (_, ref) => + val state: ServerState = ServerState.Unknown + SbtServer(ref, SbtInputs.default, state) } new AtomicReference(LoadBalancer(servers = sbtServers)) @@ -79,29 +79,24 @@ class SbtDispatcher(config: Config, progressActor: ActorRef, statusActor: ActorR } (parent ? progress).map(sender ! _) - case done: Done => - done.progress.snippetId.foreach { sid => + case done: Done => done.progress.snippetId.foreach { sid => val newBalancer = balancer.get.done(TaskId(sid)) newBalancer match { - case Some(newBalancer) => - updateSbtBalancer(newBalancer) - case None => () // never happens + case Some(newBalancer) => updateSbtBalancer(newBalancer) + case None => () // never happens } } case RunnerPong => () - case SbtUp => - log.info("SbtUp") + case SbtUp => log.info("SbtUp") case format: FormatRequest => val server = balancer.get.getRandomServer server.foreach(_.ref.tell(format, sender())) () - - case event: DisassociatedEvent => - for { + case event: DisassociatedEvent => for { host <- event.remoteAddress.host port <- event.remoteAddress.port ref <- remoteSbtSelections.get(SocketAddress(host, port)) @@ -114,8 +109,7 @@ class SbtDispatcher(config: Config, progressActor: ActorRef, statusActor: ActorR } } - case Replay(SbtRun(snippetId, inputs, progressActor, snippetActor)) => - log.info("Replay: " + inputs.code) + case Replay(SbtRun(snippetId, inputs, progressActor, snippetActor)) => log.info("Replay: " + inputs.code) case RunnerConnect(runnerHostname, runnerAkkaPort) => if (!remoteSbtSelections.contains(SocketAddress(runnerHostname, runnerAkkaPort))) { @@ -136,11 +130,9 @@ class SbtDispatcher(config: Config, progressActor: ActorRef, statusActor: ActorR ) } - case ReceiveStatus(requester) => - sender() ! LoadBalancerInfo(balancer.get, requester) + case ReceiveStatus(requester) => sender() ! LoadBalancerInfo(balancer.get, requester) - case Run(InputsWithIpAndUser(sbtTask: SbtInputs, userTrace), snippetId) => - run0(sbtTask, userTrace, snippetId) + case Run(InputsWithIpAndUser(sbtTask: SbtInputs, userTrace), snippetId) => run0(sbtTask, userTrace, snippetId) case p: Ping.type => val sender = this.sender() @@ -148,8 +140,9 @@ class SbtDispatcher(config: Config, progressActor: ActorRef, statusActor: ActorR } private def logError[T](f: Future[T]) = { - f.recover { - case e => log.error(e, "failed future") + f.recover { case e => + log.error(e, "failed future") } } + } diff --git a/balancer/src/main/scala/org/scastie/balancer/SbtServer.scala b/balancer/src/main/scala/org/scastie/balancer/SbtServer.scala index 2866f232b..2f787b400 100644 --- a/balancer/src/main/scala/org/scastie/balancer/SbtServer.scala +++ b/balancer/src/main/scala/org/scastie/balancer/SbtServer.scala @@ -1,16 +1,16 @@ package org.scastie.balancer -import org.scastie.api._ - import scala.util.Random +import org.scastie.api._ + case class SbtServer[R, S]( ref: R, lastConfig: SbtInputs, state: S, mailbox: Vector[Task[SbtInputs]] = Vector.empty, history: TaskHistory = TaskHistory(Vector.empty, 1000), - id: Int = Random.nextInt(), + id: Int = Random.nextInt() ) { def currentTaskId: Option[TaskId] = mailbox.headOption.map(_.taskId) @@ -21,11 +21,12 @@ case class SbtServer[R, S]( copy( lastConfig = done.headOption.map(_.config).getOrElse(lastConfig), mailbox = newMailbox, - history = done.foldLeft(history)(_.add(_)), + history = done.foldLeft(history)(_.add(_)) ) } def add(task: Task[SbtInputs]): SbtServer[R, S] = { copy(mailbox = mailbox :+ task) } + } diff --git a/balancer/src/main/scala/org/scastie/balancer/ScalaCliDispatcher.scala b/balancer/src/main/scala/org/scastie/balancer/ScalaCliDispatcher.scala index 2dc56aa20..2cc4ab509 100644 --- a/balancer/src/main/scala/org/scastie/balancer/ScalaCliDispatcher.scala +++ b/balancer/src/main/scala/org/scastie/balancer/ScalaCliDispatcher.scala @@ -1,22 +1,23 @@ package org.scastie.balancer +import java.time.Instant +import java.util.concurrent.ConcurrentLinkedQueue +import scala.collection.concurrent.TrieMap +import scala.collection.immutable.Queue +import scala.concurrent.duration._ +import scala.jdk.CollectionConverters._ + import akka.actor.Actor import akka.actor.ActorLogging -import com.typesafe.config.Config import akka.actor.ActorRef import akka.actor.ActorSelection -import java.time.Instant -import scala.collection.immutable.Queue -import org.scastie.util.SbtTask -import org.scastie.util.ScalaCliActorTask -import org.scastie.api._ -import akka.util.Timeout -import scala.concurrent.duration._ import akka.pattern.ask import akka.remote.DisassociatedEvent -import java.util.concurrent.ConcurrentLinkedQueue -import scala.jdk.CollectionConverters._ -import scala.collection.concurrent.TrieMap +import akka.util.Timeout +import com.typesafe.config.Config +import org.scastie.api._ +import org.scastie.util.SbtTask +import org.scastie.util.ScalaCliActorTask class ScalaCliDispatcher(config: Config, progressActor: ActorRef, statusActor: ActorRef) extends BaseDispatcher[ActorSelection, ServerState](config) { @@ -40,21 +41,24 @@ class ScalaCliDispatcher(config: Config, progressActor: ActorRef, statusActor: A giveTask() } - private def enqueueAvailableServer(addr: SocketAddress, server: ActorSelection) = - if (remoteServers.contains(addr)) { - availableServersQueue.add(addr, server) - giveTask() - } - + private def enqueueAvailableServer(addr: SocketAddress, server: ActorSelection) = if (remoteServers.contains(addr)) { + availableServersQueue.add(addr, server) + giveTask() + } private def giveTask() = { val maybeTask = Option(taskQueue.poll()) maybeTask.map { task => Option(availableServersQueue.poll) match { - case None => () + case None => () case Some((addr, server)) => { log.info(s"Giving task ${task.taskId} to ${server.pathString}") - server ! ScalaCliActorTask(task.taskId.snippetId, task.config.asInstanceOf[ScalaCliInputs], task.ip.v, progressActor) + server ! ScalaCliActorTask( + task.taskId.snippetId, + task.config.asInstanceOf[ScalaCliInputs], + task.ip.v, + progressActor + ) processedSnippetsId.addOne(task.taskId.snippetId, (addr, server)) } } @@ -90,8 +94,7 @@ class ScalaCliDispatcher(config: Config, progressActor: ActorRef, statusActor: A } (parent ? progress).map(sender ! _) - case done: Done => - done.progress.snippetId.foreach { sid => + case done: Done => done.progress.snippetId.foreach { sid => val (addr, server) = processedSnippetsId(sid) log.info(s"Runner $addr has finished processing $sid.") processedSnippetsId.remove(sid) @@ -101,8 +104,7 @@ class ScalaCliDispatcher(config: Config, progressActor: ActorRef, statusActor: A case Run(InputsWithIpAndUser(scalaCliInputs: ScalaCliInputs, userTrace), snippetId) => run0(scalaCliInputs, userTrace, snippetId) - case event: DisassociatedEvent => - for { + case event: DisassociatedEvent => for { host <- event.remoteAddress.host port <- event.remoteAddress.port ref <- remoteServers.get(SocketAddress(host, port)) @@ -113,4 +115,5 @@ class ScalaCliDispatcher(config: Config, progressActor: ActorRef, statusActor: A case _ => () } + } diff --git a/balancer/src/main/scala/org/scastie/balancer/StatusActor.scala b/balancer/src/main/scala/org/scastie/balancer/StatusActor.scala index 4dae8e3af..9bbb79d58 100644 --- a/balancer/src/main/scala/org/scastie/balancer/StatusActor.scala +++ b/balancer/src/main/scala/org/scastie/balancer/StatusActor.scala @@ -1,14 +1,12 @@ package org.scastie.balancer -import org.scastie.api._ - -import akka.actor.{Actor, ActorLogging, ActorRef, Props} -import akka.stream.scaladsl.Source import java.util.concurrent.TimeUnit - import scala.collection.mutable import scala.concurrent.duration._ +import akka.actor.{Actor, ActorLogging, ActorRef, Props} +import akka.stream.scaladsl.Source +import org.scastie.api._ import org.scastie.util.GraphStageForwarder case object SubscribeStatus @@ -21,6 +19,7 @@ case class SetDispatcher(dispatchActor: ActorRef) object StatusActor { def props: Props = Props(new StatusActor) } + class StatusActor private () extends Actor with ActorLogging { private var publishers = mutable.Buffer.empty[ActorRef] @@ -29,16 +28,14 @@ class StatusActor private () extends Actor with ActorLogging { override def receive: Receive = { case SubscribeStatus => { - val publisherGraphStage = - new GraphStageForwarder("StatusActor-GraphStageForwarder", self, None) + val publisherGraphStage = new GraphStageForwarder("StatusActor-GraphStageForwarder", self, None) - val source = - Source - .fromGraph(publisherGraphStage) - .keepAlive( - FiniteDuration(1, TimeUnit.SECONDS), - () => StatusProgress.KeepAlive - ) + val source = Source + .fromGraph(publisherGraphStage) + .keepAlive( + FiniteDuration(1, TimeUnit.SECONDS), + () => StatusProgress.KeepAlive + ) sender() ! source } @@ -63,14 +60,14 @@ class StatusActor private () extends Actor with ActorLogging { private def convertSbt(newSbtBalancer: SbtBalancer): StatusProgress = { StatusProgress.Sbt( - newSbtBalancer.servers.map( - server => - SbtRunnerState( - config = server.lastConfig, - tasks = server.mailbox.map(_.taskId), - sbtState = server.state + newSbtBalancer.servers.map(server => + SbtRunnerState( + config = server.lastConfig, + tasks = server.mailbox.map(_.taskId), + sbtState = server.state ) ) ) } + } diff --git a/balancer/src/test/scala/org/scastie/balancer/LoadBalancerRecoveryTest.scala b/balancer/src/test/scala/org/scastie/balancer/LoadBalancerRecoveryTest.scala index cd3821126..4793fc4ab 100644 --- a/balancer/src/test/scala/org/scastie/balancer/LoadBalancerRecoveryTest.scala +++ b/balancer/src/test/scala/org/scastie/balancer/LoadBalancerRecoveryTest.scala @@ -1,38 +1,37 @@ package org.scastie.balancer +import scala.concurrent._ +import scala.concurrent.duration._ + import akka.actor.{ActorSystem, Props} import akka.pattern.ask import akka.testkit.{ImplicitSender, TestKit, TestProbe} import akka.util.Timeout +import com.typesafe.config.{Config, ConfigFactory} +import org.scalatest.funsuite.AnyFunSuiteLike +import org.scalatest.BeforeAndAfterAll import org.scastie.api._ import org.scastie.sbt._ import org.scastie.util.ReconnectInfo -import com.typesafe.config.{Config, ConfigFactory} -import org.scalatest.BeforeAndAfterAll -import org.scalatest.funsuite.AnyFunSuiteLike - -import scala.concurrent._ -import scala.concurrent.duration._ class LoadBalancerRecoveryTest() - extends TestKit( - ActorSystem("LoadBalancerRecoveryTest", RemotePortConfig(0)) - ) - with ImplicitSender - with AnyFunSuiteLike - with BeforeAndAfterAll { + extends TestKit( + ActorSystem("LoadBalancerRecoveryTest", RemotePortConfig(0)) + ) + with ImplicitSender + with AnyFunSuiteLike + with BeforeAndAfterAll { // import system.dispatcher implicit val timeout: Timeout = Timeout(25.seconds) test("recover from crash") { - val crash = - """|val f = classOf[sun.misc.Unsafe].getDeclaredField("theUnsafe") - |f.setAccessible(true) - |val unsafe = f.get(null).asInstanceOf[sun.misc.Unsafe] - |println("TRYING TO CRASH JVM") - |unsafe.putLong(0, 0) - |println("SHOULD HAVE CRASHED!")""".stripMargin + val crash = """|val f = classOf[sun.misc.Unsafe].getDeclaredField("theUnsafe") + |f.setAccessible(true) + |val unsafe = f.get(null).asInstanceOf[sun.misc.Unsafe] + |println("TRYING TO CRASH JVM") + |unsafe.putLong(0, 0) + |println("SHOULD HAVE CRASHED!")""".stripMargin val code1 = "println(1)" val code3 = "println(2)" @@ -57,8 +56,7 @@ class LoadBalancerRecoveryTest() private val webSystem = ActorSystem("Web", RemotePortConfig(serverAkkaPort)) private val sbtAkkaPort = 5150 - private val sbtSystem = - ActorSystem("SbtRunner", RemotePortConfig(sbtAkkaPort)) + private val sbtSystem = ActorSystem("SbtRunner", RemotePortConfig(sbtAkkaPort)) private val progressActor = TestProbe() private val statusActor = TestProbe() @@ -66,27 +64,26 @@ class LoadBalancerRecoveryTest() private val localhost = "127.0.0.1" - private val sbtActor = - sbtSystem.actorOf( - Props( - new SbtActor( - system = sbtSystem, - runTimeout = 10.seconds, - reloadTimeout = 20.seconds, - isProduction = false, - readyRef = Some(sbtActorReadyProbe.ref), - reconnectInfo = Some( - ReconnectInfo( - serverHostname = localhost, - serverAkkaPort = serverAkkaPort, - actorHostname = localhost, - actorAkkaPort = sbtAkkaPort - ) + private val sbtActor = sbtSystem.actorOf( + Props( + new SbtActor( + system = sbtSystem, + runTimeout = 10.seconds, + reloadTimeout = 20.seconds, + isProduction = false, + readyRef = Some(sbtActorReadyProbe.ref), + reconnectInfo = Some( + ReconnectInfo( + serverHostname = localhost, + serverAkkaPort = serverAkkaPort, + actorHostname = localhost, + actorAkkaPort = sbtAkkaPort ) ) - ), - name = "SbtActor" - ) + ) + ), + name = "SbtActor" + ) sbtActorReadyProbe.fishForMessage(60.seconds) { case SbtActorReady => { @@ -101,20 +98,19 @@ class LoadBalancerRecoveryTest() } } - private val dispatchActor = - webSystem.actorOf( - Props(new DispatchActor(progressActor.ref, statusActor.ref)), - name = "DispatchActor" - ) + private val dispatchActor = webSystem.actorOf( + Props(new DispatchActor(progressActor.ref, statusActor.ref)), + name = "DispatchActor" + ) private var id = 0 + private def run(code: String): SnippetId = { - val wrapped = - s"""|object Main { - | def main(args: Array[String]): Unit = { - | $code - | } - |}""".stripMargin + val wrapped = s"""|object Main { + | def main(args: Array[String]): Unit = { + | $code + | } + |}""".stripMargin val inputs = SbtInputs.default.copy(code = wrapped, isWorksheetMode = false) @@ -131,7 +127,7 @@ class LoadBalancerRecoveryTest() } private def waitFor(sid: SnippetId, ret: Map[SnippetId, String])( - f: SnippetProgress => Boolean + f: SnippetProgress => Boolean ): Unit = { progressActor.fishForMessage(50.seconds) { @@ -159,22 +155,24 @@ class LoadBalancerRecoveryTest() TestKit.shutdownActorSystem(sbtSystem) TestKit.shutdownActorSystem(system) } + } object RemotePortConfig { - def apply(port: Int): Config = - ConfigFactory.parseString( - s"""|akka { - | actor { - | provider = cluster - | allow-java-serialization = on - | } - | remote { - | artery.canonical { - | hostname = "127.0.0.1" - | port = $port - | } - | } - |}""".stripMargin - ) + + def apply(port: Int): Config = ConfigFactory.parseString( + s"""|akka { + | actor { + | provider = cluster + | allow-java-serialization = on + | } + | remote { + | artery.canonical { + | hostname = "127.0.0.1" + | port = $port + | } + | } + |}""".stripMargin + ) + } diff --git a/balancer/src/test/scala/org/scastie/balancer/LoadBalancerTest.scala b/balancer/src/test/scala/org/scastie/balancer/LoadBalancerTest.scala index 00cb6c8f3..c5305c2b3 100644 --- a/balancer/src/test/scala/org/scastie/balancer/LoadBalancerTest.scala +++ b/balancer/src/test/scala/org/scastie/balancer/LoadBalancerTest.scala @@ -2,6 +2,7 @@ package org.scastie package balancer import java.time.Instant + import org.scastie.api.ServerState class LoadBalancerTest extends LoadBalancerTestUtils { @@ -12,7 +13,7 @@ class LoadBalancerTest extends LoadBalancerTestUtils { 1 * "c2", 1 * "c3", 1 * "c4" - ), + ) ) assertConfigs(add(balancer, sbtConfig("c8")))( @@ -28,7 +29,7 @@ class LoadBalancerTest extends LoadBalancerTestUtils { val balancer = LoadBalancer( servers( 5 * "c1" - ), + ) ) assertConfigs(add(balancer, sbtConfig("c1")))( @@ -40,7 +41,7 @@ class LoadBalancerTest extends LoadBalancerTestUtils { val balancer = LoadBalancer( servers( 5 * "c1" - ), + ) ) assertConfigs(add(balancer, sbtConfig("c2")))( 4 * "c1", @@ -50,7 +51,7 @@ class LoadBalancerTest extends LoadBalancerTestUtils { test("server notify when it's done") { val balancer = LoadBalancer( - servers(1 * "c1"), + servers(1 * "c1") ) val server = balancer.servers.head @@ -70,7 +71,7 @@ class LoadBalancerTest extends LoadBalancerTestUtils { test("run two tasks") { val balancer = LoadBalancer( - servers(1 * "c1"), + servers(1 * "c1") ) val server = balancer.servers.head @@ -78,8 +79,7 @@ class LoadBalancerTest extends LoadBalancerTestUtils { assert(server.currentTaskId.isEmpty) val taskId1 = TestTaskId(1) - val (assigned0, balancer0) = - balancer.add(Task(sbtConfig("c1"), nextIp, taskId1, Instant.now)).get + val (assigned0, balancer0) = balancer.add(Task(sbtConfig("c1"), nextIp, taskId1, Instant.now)).get val server0 = balancer0.servers.head @@ -88,8 +88,7 @@ class LoadBalancerTest extends LoadBalancerTestUtils { assert(server0.currentTaskId.contains(taskId1)) val taskId2 = TestTaskId(2) - val (assigned1, balancer1) = - balancer0.add(Task(sbtConfig("c2"), nextIp, taskId2, Instant.now)).get + val (assigned1, balancer1) = balancer0.add(Task(sbtConfig("c2"), nextIp, taskId2, Instant.now)).get val server1 = balancer1.servers.head assert(server1.mailbox.size == 2) @@ -110,7 +109,7 @@ class LoadBalancerTest extends LoadBalancerTestUtils { val ref = TestServerRef(1) val balancer = LoadBalancer( - Vector(SbtServer(ref, sbtConfig("c1"), ServerState.Unknown)), + Vector(SbtServer(ref, sbtConfig("c1"), ServerState.Unknown)) ) assert(balancer.removeServer(ref).servers.isEmpty) } @@ -118,7 +117,7 @@ class LoadBalancerTest extends LoadBalancerTestUtils { test("empty balancer") { val emptyBalancer = LoadBalancer( - servers = Vector(), + servers = Vector() ) val task = Task(code("c1"), nextIp, TestTaskId(1), Instant.now) diff --git a/balancer/src/test/scala/org/scastie/balancer/LoadBalancerTestUtils.scala b/balancer/src/test/scala/org/scastie/balancer/LoadBalancerTestUtils.scala index c10dbcc34..b0b322c22 100644 --- a/balancer/src/test/scala/org/scastie/balancer/LoadBalancerTestUtils.scala +++ b/balancer/src/test/scala/org/scastie/balancer/LoadBalancerTestUtils.scala @@ -2,9 +2,9 @@ package org.scastie.balancer import java.time.Instant -import org.scastie.api._ -import org.scalatest.Assertion import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.Assertion +import org.scastie.api._ import org.scastie.util.SbtTask object TestTaskId { @@ -19,6 +19,7 @@ trait LoadBalancerTestUtils extends AnyFunSuite with TestUtils { type TestLoadBalancer0 = LoadBalancer[TestServerRef, ServerState] @transient private var taskId = 1000 + def add(balancer: TestLoadBalancer0, config: SbtInputs): TestLoadBalancer0 = synchronized { val (_, balancer0) = balancer.add(Task(config, nextIp, TestTaskId(taskId), Instant.now)).get taskId += 1 @@ -27,20 +28,22 @@ trait LoadBalancerTestUtils extends AnyFunSuite with TestUtils { // Ordering only for debug purposes object Multiset { - def apply[T: Ordering](xs: Seq[T]): Multiset[T] = - Multiset(xs.groupBy(x => x).map { case (k, vs) => (k, vs.size) }) + def apply[T: Ordering](xs: Seq[T]): Multiset[T] = Multiset(xs.groupBy(x => x).map { case (k, vs) => (k, vs.size) }) } + case class Multiset[T: Ordering](inner: Map[T, Int]) { + override def toString: String = { val size = inner.values.sum inner.toList .sortBy { case (k, v) => (-v, k) } - .map { - case (k, v) => s"$k($v)" + .map { case (k, v) => + s"$k($v)" } .mkString("Multiset(", ", ", s") {$size}") } + } def assertConfigs(balancer: TestLoadBalancer0)(columns: Seq[String]*): Assertion = { @@ -52,10 +55,11 @@ trait LoadBalancerTestUtils extends AnyFunSuite with TestUtils { } @transient private var serverId = 0 + def server( - c: String, - mailbox: Vector[Task[SbtInputs]] = Vector(), - state: ServerState = ServerState.Unknown + c: String, + mailbox: Vector[Task[SbtInputs]] = Vector(), + state: ServerState = ServerState.Unknown ): TestServer0 = synchronized { val t = SbtServer(TestServerRef(serverId), sbtConfig(c), state, mailbox) serverId += 1 @@ -67,6 +71,7 @@ trait LoadBalancerTestUtils extends AnyFunSuite with TestUtils { } @transient private var currentIp = 0 + def nextIp: Ip = synchronized { val t = Ip("ip" + currentIp) currentIp += 1 @@ -79,9 +84,13 @@ trait LoadBalancerTestUtils extends AnyFunSuite with TestUtils { def sbtConfig(sbtConfig: String) = SbtInputs.default.copy(sbtConfigExtra = sbtConfig) def history(columns: Seq[String]*): TaskHistory = { - val records = - columns.to(Vector).flatten.map(i => Task(SbtInputs.default.copy(code = i.toString), nextIp, TestTaskId(1), Instant.now)).reverse + val records = columns + .to(Vector) + .flatten + .map(i => Task(SbtInputs.default.copy(code = i.toString), nextIp, TestTaskId(1), Instant.now)) + .reverse TaskHistory(Vector(records: _*), maxSize = 20) } + } diff --git a/balancer/src/test/scala/org/scastie/balancer/TestUtils.scala b/balancer/src/test/scala/org/scastie/balancer/TestUtils.scala index 6edc12e07..0dc17b6b7 100644 --- a/balancer/src/test/scala/org/scastie/balancer/TestUtils.scala +++ b/balancer/src/test/scala/org/scastie/balancer/TestUtils.scala @@ -2,7 +2,9 @@ package org.scastie package balancer trait TestUtils { + implicit class IntExtension(n: Int) { def *[T](v: T): Seq[T] = List.fill(n)(v) } + } diff --git a/client/src/main/scala/org/scastie/client/AnsiColorFormatter.scala b/client/src/main/scala/org/scastie/client/AnsiColorFormatter.scala index 2a1bbf574..81bc60c17 100644 --- a/client/src/main/scala/org/scastie/client/AnsiColorFormatter.scala +++ b/client/src/main/scala/org/scastie/client/AnsiColorFormatter.scala @@ -31,16 +31,16 @@ object AnsiColorFormatter extends AnsiColor { def formatToHtml(unformatted: String): String = { unformatted - .foldLeft("" -> 0) { - case ((_r, d), c) => - val r = _r + c - val replaced = colors.collectFirst { - case (ansiCode, replacement) if r.endsWith(ansiCode) => - if (ansiCode == RESET) r.replace(ansiCode, "" * d) -> 0 - else r.replace(ansiCode, s"""""") -> (d + 1) - } - replaced.getOrElse(r -> d) + .foldLeft("" -> 0) { case ((_r, d), c) => + val r = _r + c + val replaced = colors.collectFirst { + case (ansiCode, replacement) if r.endsWith(ansiCode) => + if (ansiCode == RESET) r.replace(ansiCode, "" * d) -> 0 + else r.replace(ansiCode, s"""""") -> (d + 1) + } + replaced.getOrElse(r -> d) } ._1 } + } diff --git a/client/src/main/scala/org/scastie/client/ClientMain.scala b/client/src/main/scala/org/scastie/client/ClientMain.scala index 06fddf628..16d6ec58d 100644 --- a/client/src/main/scala/org/scastie/client/ClientMain.scala +++ b/client/src/main/scala/org/scastie/client/ClientMain.scala @@ -1,18 +1,16 @@ package org.scastie.client import java.util.UUID +import scala.scalajs.js +import scala.scalajs.js.{|, UndefOr} +import scala.scalajs.js.annotation.{JSExport, _} -import org.scastie.api.SnippetId -import org.scastie.client.components._ import japgolly.scalajs.react.component.Generic import japgolly.scalajs.react.extra.router._ import org.scalajs.dom -import org.scalajs.dom.{HTMLElement, HTMLDivElement, HTMLLinkElement, Node} - -import scala.scalajs.js -import scala.scalajs.js.annotation.{JSExport, _} -import scala.scalajs.js.{UndefOr, |} - +import org.scalajs.dom.{HTMLDivElement, HTMLElement, HTMLLinkElement, Node} +import org.scastie.api.SnippetId +import org.scastie.client.components._ import org.scastie.client.i18n.I18n @js.native @@ -27,35 +25,37 @@ object Exports { val ScastieMain = org.scastie.client.ScastieMain @JSExport val ClientMain = org.scastie.client.ScastieClientMain + @JSExport - def Embedded(selector: UndefOr[String | Node], options: UndefOr[EmbeddedOptionsJs]) = ScastieEmbedded.embedded(selector, options) + def Embedded(selector: UndefOr[String | Node], options: UndefOr[EmbeddedOptionsJs]) = + ScastieEmbedded.embedded(selector, options) + @JSExport def EmbeddedResource(options: UndefOr[EmbeddedResourceOptionsJs]) = ScastieEmbedded.embeddedResource(options) } + /* Entry point for the website */ object ScastieMain { + @JSExport def main(): Unit = { dom.document.body.className = "scastie" val stateJson = Option(dom.window.localStorage.getItem("state")) - val savedLanguage = - stateJson - .flatMap { json => - import scala.scalajs.js.JSON - val parsed = JSON.parse(json) - if (js.DynamicImplicits.truthValue(parsed.selectDynamic("language"))) - Some(parsed.selectDynamic("language").asInstanceOf[String]) - else - None - } - .getOrElse("en") + val savedLanguage = stateJson + .flatMap { json => + import scala.scalajs.js.JSON + val parsed = JSON.parse(json) + if (js.DynamicImplicits.truthValue(parsed.selectDynamic("language"))) + Some(parsed.selectDynamic("language").asInstanceOf[String]) + else None + } + .getOrElse("en") I18n.setLanguage(savedLanguage) - val container = - dom.document.createElement("div").asInstanceOf[HTMLDivElement] + val container = dom.document.createElement("div").asInstanceOf[HTMLDivElement] container.className = "scastie" dom.document.body.appendChild(container) @@ -71,11 +71,13 @@ object ScastieMain { () } + } /* Entry point for Scala.js runtime */ object ScastieClientMain { + @JSExport def signal(instrumentations: String, attachedDoms: js.Array[HTMLElement], rawId: String): Unit = { Global.signal(instrumentations, attachedDoms, rawId) @@ -85,30 +87,28 @@ object ScastieClientMain { def error(er: js.Error, rawId: String): Unit = { Global.error(er, rawId) } + } /* Entry point for ressource embedding and code embedding */ object ScastieEmbedded { + def embedded(selector: UndefOr[String | Node], options: UndefOr[EmbeddedOptionsJs]): Unit = { - val embeddedOptions = - options.toOption - .map(EmbeddedOptions.fromJs(Settings.defaultServerUrl)) - .getOrElse(EmbeddedOptions.empty(Settings.defaultServerUrl)) - - val nodes = - selector.toOption match { - case Some(sel) => { - (sel: Any) match { - case cssSelector: String => - dom.document.querySelectorAll(cssSelector).toList - case node: Node => - List(node) - } + val embeddedOptions = options.toOption + .map(EmbeddedOptions.fromJs(Settings.defaultServerUrl)) + .getOrElse(EmbeddedOptions.empty(Settings.defaultServerUrl)) + + val nodes = selector.toOption match { + case Some(sel) => { + (sel: Any) match { + case cssSelector: String => dom.document.querySelectorAll(cssSelector).toList + case node: Node => List(node) } - case None => List() } + case None => List() + } if (nodes.nonEmpty) { addStylesheet(embeddedOptions.serverUrl) @@ -135,16 +135,14 @@ object ScastieEmbedded { } def embeddedResource(options: UndefOr[EmbeddedResourceOptionsJs]): Unit = { - val embeddedOptions = - options.toOption - .map(EmbeddedOptions.fromJsRessource(Settings.defaultServerUrl)) - .getOrElse(EmbeddedOptions.empty(Settings.defaultServerUrl)) - - val container = - renderScastie( - embeddedOptions = embeddedOptions, - snippetId = embeddedOptions.snippetId - ) + val embeddedOptions = options.toOption + .map(EmbeddedOptions.fromJsRessource(Settings.defaultServerUrl)) + .getOrElse(EmbeddedOptions.empty(Settings.defaultServerUrl)) + + val container = renderScastie( + embeddedOptions = embeddedOptions, + snippetId = embeddedOptions.snippetId + ) embeddedOptions.injectId match { case Some(id) => { @@ -163,6 +161,7 @@ object ScastieEmbedded { } } } + def addStylesheet(baseUrl: String): Unit = { val link = dom.document .createElement("link") @@ -192,9 +191,10 @@ object ScastieEmbedded { targetType = None, tryLibrary = None, code = None, - inputs = None, + inputs = None ).render.renderIntoDOM(container) container } + } diff --git a/client/src/main/scala/org/scastie/client/ConsoleState.scala b/client/src/main/scala/org/scastie/client/ConsoleState.scala index f57b132c4..78c04919e 100644 --- a/client/src/main/scala/org/scastie/client/ConsoleState.scala +++ b/client/src/main/scala/org/scastie/client/ConsoleState.scala @@ -12,6 +12,7 @@ object ConsoleState { consoleHasUserOutput = false, userOpenedConsole = false ) + } case class ConsoleState( diff --git a/client/src/main/scala/org/scastie/client/EmbeddedOptions.scala b/client/src/main/scala/org/scastie/client/EmbeddedOptions.scala index 6dea6aebb..174b83502 100644 --- a/client/src/main/scala/org/scastie/client/EmbeddedOptions.scala +++ b/client/src/main/scala/org/scastie/client/EmbeddedOptions.scala @@ -1,10 +1,10 @@ package org.scastie.client -import org.scastie.api._ - import scala.scalajs.js import scala.scalajs.js.UndefOr +import org.scastie.api._ + trait SharedEmbeddedOptions extends js.Object { val serverUrl: UndefOr[String] val theme: UndefOr[String] @@ -33,19 +33,23 @@ trait EmbeddedOptionsJs extends js.Object with SharedEmbeddedOptions { // val scalaNativeVersion: UndefOr[String] not yet supported } -case class EmbeddedOptions(snippetId: Option[SnippetId], - injectId: Option[String], - inputs: Option[BaseInputs], - theme: Option[String], - serverUrl: String) { +case class EmbeddedOptions( + snippetId: Option[SnippetId], + injectId: Option[String], + inputs: Option[BaseInputs], + theme: Option[String], + serverUrl: String +) { def setCode(code: String): EmbeddedOptions = { val inputs0: BaseInputs = inputs.getOrElse(ScalaCliInputs.default) copy(inputs = Some(inputs0.copyBaseInput(code = code))) } + } object EmbeddedOptions { + def empty(defaultServerUrl: String): EmbeddedOptions = { EmbeddedOptions( snippetId = None, @@ -57,22 +61,21 @@ object EmbeddedOptions { } private def extractSnippetId( - options: SharedEmbeddedOptions + options: SharedEmbeddedOptions ): Option[SnippetId] = { import options._ - base64UUID.toOption.map( - uuid => - SnippetId( - uuid, - user.toOption - .map(u => SnippetUserPart(u, update.toOption.getOrElse(0))) + base64UUID.toOption.map(uuid => + SnippetId( + uuid, + user.toOption + .map(u => SnippetUserPart(u, update.toOption.getOrElse(0))) ) ) } def fromJsRessource( - defaultServerUrl: String + defaultServerUrl: String )(options: EmbeddedResourceOptionsJs): EmbeddedOptions = { import options._ @@ -95,85 +98,83 @@ object EmbeddedOptions { } def fromJs( - defaultServerUrl: String + defaultServerUrl: String )(options: EmbeddedOptionsJs): EmbeddedOptions = { import options._ - val scalaTarget = - (targetType.toOption, - scalaVersion.toOption, - None: Option[String], // scalaJsVersion.toOption, - None: Option[String] // scalaNativeVersion.toOption - ) match { - - case (Some("jvm"), _, None, None) => { - Some( - scalaVersion - .map(version => Scala2(version)) - .getOrElse(Scala2.default) - ) - } - - case (Some("dotty" | "scala3"), _, None, None) => { - Some( - scalaVersion - .map(version => Scala3(version)) - .getOrElse(Scala3.default) - ) - } - - case (Some("typelevel"), _, None, None) => { - Some( - scalaVersion - .map(version => Typelevel(version)) - .getOrElse(Typelevel.default) - ) - } - - case (Some("js"), None, None, None) => { - Some(Js.default) - } - - case (tpe, Some(scalaV), Some(jsV), None) if (tpe.contains("js") || tpe.isEmpty) => { - - Some(Js(scalaV, jsV)) - } - - case (Some("native"), None, None, None) => { - Some(Native.default) - } - - case (tpe, Some(scalaV), None, Some(nativeV)) if (tpe.contains("native") || tpe.isEmpty) => { - Some(Native(scalaV, nativeV)) - } - - case (None, None, None, None) => None - - case (a, b, c, d) => { - sys.error( - s"invalid scala target combination: $a | $b | $c | $d" - ) - } + val scalaTarget = ( + targetType.toOption, + scalaVersion.toOption, + None: Option[String], // scalaJsVersion.toOption, + None: Option[String] // scalaNativeVersion.toOption + ) match { + + case (Some("jvm"), _, None, None) => { + Some( + scalaVersion + .map(version => Scala2(version)) + .getOrElse(Scala2.default) + ) + } + + case (Some("dotty" | "scala3"), _, None, None) => { + Some( + scalaVersion + .map(version => Scala3(version)) + .getOrElse(Scala3.default) + ) + } + + case (Some("typelevel"), _, None, None) => { + Some( + scalaVersion + .map(version => Typelevel(version)) + .getOrElse(Typelevel.default) + ) + } + + case (Some("js"), None, None, None) => { + Some(Js.default) } + case (tpe, Some(scalaV), Some(jsV), None) if (tpe.contains("js") || tpe.isEmpty) => { + + Some(Js(scalaV, jsV)) + } + + case (Some("native"), None, None, None) => { + Some(Native.default) + } + + case (tpe, Some(scalaV), None, Some(nativeV)) if (tpe.contains("native") || tpe.isEmpty) => { + Some(Native(scalaV, nativeV)) + } + + case (None, None, None, None) => None + + case (a, b, c, d) => { + sys.error( + s"invalid scala target combination: $a | $b | $c | $d" + ) + } + } + val inputs = if (scalaTarget.isDefined || code.isDefined) { val default = ScalaCliInputs.default - val isScala3 = - scalaTarget - .map(_.targetType == ScalaTargetType.Scala3) - .getOrElse(false) + val isScala3 = scalaTarget + .map(_.targetType == ScalaTargetType.Scala3) + .getOrElse(false) val defaultCode = if (isScala3) Scala3.defaultCode else default.code - val inputs0 = - default.copy( - isWorksheetMode = isWorksheetMode.getOrElse(default.isWorksheetMode), - code = code.getOrElse(defaultCode), - ) + val inputs0 = default.copy( + isWorksheetMode = isWorksheetMode.getOrElse(default.isWorksheetMode), + code = code.getOrElse(defaultCode) + ) Some(inputs0) } else { None @@ -195,4 +196,5 @@ object EmbeddedOptions { serverUrl = serverUrl.toOption.getOrElse(defaultServerUrl) ) } + } diff --git a/client/src/main/scala/org/scastie/client/EventStream.scala b/client/src/main/scala/org/scastie/client/EventStream.scala index 9afe0971b..d0c5dc6dc 100644 --- a/client/src/main/scala/org/scastie/client/EventStream.scala +++ b/client/src/main/scala/org/scastie/client/EventStream.scala @@ -1,20 +1,19 @@ package org.scastie.client +import scala.util.Failure +import scala.util.Success + import io.circe._ import io.circe.parser._ import io.circe.syntax._ - import japgolly.scalajs.react.Callback import japgolly.scalajs.react.CallbackTo +import org.scalajs.dom.window import org.scalajs.dom.CloseEvent import org.scalajs.dom.Event import org.scalajs.dom.EventSource import org.scalajs.dom.MessageEvent import org.scalajs.dom.WebSocket -import org.scalajs.dom.window - -import scala.util.Failure -import scala.util.Success abstract class EventStream[T: Decoder](handler: EventStreamHandler[T]) { var closing = false @@ -29,6 +28,7 @@ abstract class EventStream[T: Decoder](handler: EventStreamHandler[T]) { } } } + def onOpen(): Unit = handler.onOpen() def onError(error: String): Unit = handler.onError(error) def onClose(reason: Option[String]): Unit = handler.onClose(reason) @@ -39,6 +39,7 @@ abstract class EventStream[T: Decoder](handler: EventStreamHandler[T]) { onClose(None) } } + } trait EventStreamHandler[T] { @@ -52,17 +53,16 @@ trait EventStreamHandler[T] { } object EventStream { + def connect[T: Decoder](eventSourceUri: String, websocketUri: String, handler: EventStreamHandler[T]): Callback = { - def connectEventSource = - CallbackTo[EventStream[T]]( - new EventSourceStream(eventSourceUri, handler) - ) + def connectEventSource = CallbackTo[EventStream[T]]( + new EventSourceStream(eventSourceUri, handler) + ) - def connectWebSocket = - CallbackTo[EventStream[T]]( - new WebSocketStream(websocketUri, handler) - ) + def connectWebSocket = CallbackTo[EventStream[T]]( + new WebSocketStream(websocketUri, handler) + ) connectEventSource.attemptTry.flatMap { case Success(eventSource) => { @@ -81,6 +81,7 @@ object EventStream { } } } + } class WebSocketStream[T: Decoder](uri: String, handler: EventStreamHandler[T]) extends EventStream[T](handler) { @@ -102,8 +103,7 @@ class WebSocketStream[T: Decoder](uri: String, handler: EventStreamHandler[T]) e socket.close() } - val protocol: String = - if (window.location.protocol == "https:") "wss" else "ws" + val protocol: String = if (window.location.protocol == "https:") "wss" else "ws" val fullUri: String = s"$protocol://${window.location.host}${uri}" val socket: WebSocket = new WebSocket(uri) diff --git a/client/src/main/scala/org/scastie/client/Global.scala b/client/src/main/scala/org/scastie/client/Global.scala index 8498b4de9..fbfa484e4 100644 --- a/client/src/main/scala/org/scastie/client/Global.scala +++ b/client/src/main/scala/org/scastie/client/Global.scala @@ -1,23 +1,19 @@ package org.scastie.client -import org.scastie.api._ -import org.scastie.runtime.api._ +import java.util.UUID +import scala.collection.mutable.{Map => MMap} +import scala.scalajs.js +import scala.util.{Failure, Success, Try} + import io.circe._ import io.circe.parser._ - +import japgolly.scalajs.react._ +import org.scalajs.dom.HTMLElement +import org.scastie.api._ import org.scastie.client.components.Scastie +import org.scastie.runtime.api._ import RuntimeCodecs._ -import scala.scalajs.js -import scala.collection.mutable.{Map => MMap} -import scala.util.{Try, Failure, Success} - -import org.scalajs.dom.HTMLElement - -import japgolly.scalajs.react._ - -import java.util.UUID - object Global { type Scope = BackendScope[Scastie, ScastieState] @@ -33,21 +29,20 @@ object Global { def error(er: js.Error, rawId: String): Unit = { withScope(rawId)( - _.withEffectsImpure.modState( - state => - state - .copyAndSave( - outputs = state.outputs.copy( - runtimeError = Some( - RuntimeError( - message = er.toString, - line = None, - fullStack = "" - ) + _.withEffectsImpure.modState(state => + state + .copyAndSave( + outputs = state.outputs.copy( + runtimeError = Some( + RuntimeError( + message = er.toString, + line = None, + fullStack = "" ) ) ) - .setRunning(false) + ) + .setRunning(false) ) ) } @@ -59,18 +54,17 @@ object Global { val ScalaJsResult(instr, runtimeError) = result.getOrElse(ScalaJsResult(Nil, None)) withScope(rawId)( - _.withEffectsImpure.modState( - state => - state - .copyAndSave( - outputs = state.outputs.copy( - instrumentations = state.outputs.instrumentations ++ instr.toSet, - runtimeError = runtimeError - ) + _.withEffectsImpure.modState(state => + state + .copyAndSave( + outputs = state.outputs.copy( + instrumentations = state.outputs.instrumentations ++ instr.toSet, + runtimeError = runtimeError ) - .setRunning(false) - .copy( - attachedDoms = attachedDoms.map(dom => (dom.getAttribute("uuid"), dom)).toMap + ) + .setRunning(false) + .copy( + attachedDoms = attachedDoms.map(dom => (dom.getAttribute("uuid"), dom)).toMap ) ) ) @@ -86,4 +80,5 @@ object Global { case Failure(e) => e.printStackTrace() } } + } diff --git a/client/src/main/scala/org/scastie/client/HTMLFormatter.scala b/client/src/main/scala/org/scastie/client/HTMLFormatter.scala index 20a7db1a4..5747d6f6a 100644 --- a/client/src/main/scala/org/scastie/client/HTMLFormatter.scala +++ b/client/src/main/scala/org/scastie/client/HTMLFormatter.scala @@ -1,22 +1,21 @@ package org.scastie.client object HTMLFormatter { - private val escapeMap = - Map('&' -> "&", '"' -> """, '<' -> "<", '>' -> ">") + private val escapeMap = Map('&' -> "&", '"' -> """, '<' -> "<", '>' -> ">") - private def escape(text: String): String = - text.iterator - .foldLeft(new StringBuilder()) { (s, c) => - escapeMap.get(c) match { - case Some(str) => s ++= str - case _ if c >= ' ' || "\n\r\t\u001b".contains(c) => s += c - case _ => s // noop - } + private def escape(text: String): String = text.iterator + .foldLeft(new StringBuilder()) { (s, c) => + escapeMap.get(c) match { + case Some(str) => s ++= str + case _ if c >= ' ' || "\n\r\t\u001b".contains(c) => s += c + case _ => s // noop } - .toString + } + .toString def format(notEscapedAndUnformatted: String) = { val escaped = escape(notEscapedAndUnformatted) AnsiColorFormatter.formatToHtml(escaped) } + } diff --git a/client/src/main/scala/org/scastie/client/LocalStorage.scala b/client/src/main/scala/org/scastie/client/LocalStorage.scala index e091ad685..bb5600ad6 100644 --- a/client/src/main/scala/org/scastie/client/LocalStorage.scala +++ b/client/src/main/scala/org/scastie/client/LocalStorage.scala @@ -1,10 +1,9 @@ package org.scastie package client - -import io.circe.syntax._ import io.circe._ import io.circe.parser._ +import io.circe.syntax._ import org.scalajs.dom import org.scalajs.dom.window.localStorage @@ -25,4 +24,5 @@ object LocalStorage { None } } + } diff --git a/client/src/main/scala/org/scastie/client/ModalState.scala b/client/src/main/scala/org/scastie/client/ModalState.scala index 1764794cd..8b61ff530 100644 --- a/client/src/main/scala/org/scastie/client/ModalState.scala +++ b/client/src/main/scala/org/scastie/client/ModalState.scala @@ -1,10 +1,9 @@ package org.scastie.client -import org.scastie.api.SnippetId import io.circe._ import io.circe.generic.semiauto._ - import japgolly.scalajs.react._ +import org.scastie.api.SnippetId object ModalState { implicit val modalStateEncoder: Encoder[ModalState] = deriveEncoder[ModalState] @@ -31,6 +30,7 @@ object ModalState { isEmbeddedClosed = true, isLoginModalClosed = true ) + } case class ModalState( @@ -45,8 +45,6 @@ case class ModalState( isLoginModalClosed: Boolean ) { val isShareModalClosed: SnippetId ~=> Boolean = - Reusable.fn( - shareModalSnippetId2 => !shareModalSnippetId.contains(shareModalSnippetId2) - ) + Reusable.fn(shareModalSnippetId2 => !shareModalSnippetId.contains(shareModalSnippetId2)) } diff --git a/client/src/main/scala/org/scastie/client/RestApiClient.scala b/client/src/main/scala/org/scastie/client/RestApiClient.scala index 0a664aca4..7138a279a 100644 --- a/client/src/main/scala/org/scastie/client/RestApiClient.scala +++ b/client/src/main/scala/org/scastie/client/RestApiClient.scala @@ -1,44 +1,42 @@ package org.scastie.client -import org.scastie.api._ -import org.scalajs.dom -import org.scalajs.dom.XMLHttpRequest - import scala.concurrent.Future import scala.util.Try -import scalajs.concurrent.JSExecutionContext.Implicits.queue -import scalajs.js.Thenable.Implicits._ -import scalajs.js - import io.circe._ import io.circe.parser._ import io.circe.syntax._ +import org.scalajs.dom +import org.scalajs.dom.XMLHttpRequest +import org.scastie.api._ +import scalajs.concurrent.JSExecutionContext.Implicits.queue +import scalajs.js +import scalajs.js.Thenable.Implicits._ class RestApiClient(serverUrl: Option[String]) extends RestApi { val apiBase: String = serverUrl.getOrElse("") - def tryParse[T: Decoder](response: XMLHttpRequest): Option[T] = - tryParse(response.responseText) + def tryParse[T: Decoder](response: XMLHttpRequest): Option[T] = tryParse(response.responseText) - def tryParse[T: Decoder](response: dom.Response): Future[Option[T]] = - response.text().map(tryParse(_)) + def tryParse[T: Decoder](response: dom.Response): Future[Option[T]] = response.text().map(tryParse(_)) def tryParse[T: Decoder](text: String): Option[T] = { - Option.when(text.nonEmpty)(text).flatMap(t => - decode[T](t).toOption - ) + Option.when(text.nonEmpty)(text).flatMap(t => decode[T](t).toOption) } def get[T: Decoder](url: String): Future[Option[T]] = { val header = new dom.Headers(js.Dictionary("Accept" -> "application/json")) dom - .fetch(apiBase + "/api" + url, js.Dynamic.literal(headers = header, method = dom.HttpMethod.GET).asInstanceOf[dom.RequestInit]) + .fetch( + apiBase + "/api" + url, + js.Dynamic.literal(headers = header, method = dom.HttpMethod.GET).asInstanceOf[dom.RequestInit] + ) .flatMap(tryParse[T](_)) } class Post[O: Decoder]() { + def using[I: Encoder](url: String, data: I, async: Boolean = true): Future[Option[O]] = { val header = new dom.Headers(js.Dictionary("Accept" -> "application/json", "Content-Type" -> "application/json")) dom @@ -50,18 +48,16 @@ class RestApiClient(serverUrl: Option[String]) extends RestApi { ) .flatMap(tryParse[O](_)) } + } def post[O: Decoder]: Post[O] = new Post[O] - def run(inputs: BaseInputs): Future[SnippetId] = - post[SnippetId].using("/run", inputs).map(_.get) + def run(inputs: BaseInputs): Future[SnippetId] = post[SnippetId].using("/run", inputs).map(_.get) - def format(request: FormatRequest): Future[FormatResponse] = - post[FormatResponse].using("/format", request).map(_.get) + def format(request: FormatRequest): Future[FormatResponse] = post[FormatResponse].using("/format", request).map(_.get) - def save(inputs: BaseInputs): Future[SnippetId] = - post[SnippetId].using("/save", inputs).map(_.get) + def save(inputs: BaseInputs): Future[SnippetId] = post[SnippetId].using("/save", inputs).map(_.get) def saveBlocking(inputs: BaseInputs): Option[SnippetId] = { val req = new dom.XMLHttpRequest() @@ -86,23 +82,17 @@ class RestApiClient(serverUrl: Option[String]) extends RestApi { snippetId } - def update(editInputs: EditInputs): Future[Option[SnippetId]] = - post[SnippetId].using("/update", editInputs) + def update(editInputs: EditInputs): Future[Option[SnippetId]] = post[SnippetId].using("/update", editInputs) - def fork(editInputs: EditInputs): Future[Option[SnippetId]] = - post[SnippetId].using("/fork", editInputs) + def fork(editInputs: EditInputs): Future[Option[SnippetId]] = post[SnippetId].using("/fork", editInputs) - def delete(snippetId: SnippetId): Future[Boolean] = - post[Boolean].using("/delete", snippetId).map(_.getOrElse(false)) + def delete(snippetId: SnippetId): Future[Boolean] = post[Boolean].using("/delete", snippetId).map(_.getOrElse(false)) - def fetch(snippetId: SnippetId): Future[Option[FetchResult]] = - get[FetchResult]("/snippets/" + snippetId.url) + def fetch(snippetId: SnippetId): Future[Option[FetchResult]] = get[FetchResult]("/snippets/" + snippetId.url) - def fetchOld(id: Int): Future[Option[FetchResult]] = - get[FetchResult](s"/old-snippets/$id") + def fetchOld(id: Int): Future[Option[FetchResult]] = get[FetchResult](s"/old-snippets/$id") - def fetchUser(): Future[Option[User]] = - get[User]("/user/settings") + def fetchUser(): Future[Option[User]] = get[User]("/user/settings") @deprecated("Scheduled for removal", "2023-04-30") def getPrivacyPolicyStatus(): Future[Boolean] = @@ -110,15 +100,15 @@ class RestApiClient(serverUrl: Option[String]) extends RestApi { @deprecated("Scheduled for removal", "2023-04-30") def acceptPrivacyPolicy(): Future[Boolean] = - post[Boolean].using("/user/acceptPrivacyPolicy", "", async=false).map(_.getOrElse(false)) + post[Boolean].using("/user/acceptPrivacyPolicy", "", async = false).map(_.getOrElse(false)) @deprecated("Scheduled for removal", "2023-04-30") def removeAllUserSnippets(): Future[Boolean] = - post[Boolean].using("/user/removeAllUserSnippets", "", async=false).map(_.getOrElse(false)) + post[Boolean].using("/user/removeAllUserSnippets", "", async = false).map(_.getOrElse(false)) @deprecated("Scheduled for removal", "2023-04-30") def removeUserFromPolicyStatus(): Future[Boolean] = - post[Boolean].using("/user/removeUserFromPolicyStatus", "", async=false).map(_.getOrElse(false)) + post[Boolean].using("/user/removeUserFromPolicyStatus", "", async = false).map(_.getOrElse(false)) def fetchUserSnippets(): Future[List[SnippetSummary]] = get[List[SnippetSummary]]("/user/snippets").map(_.getOrElse(Nil)) diff --git a/client/src/main/scala/org/scastie/client/Routing.scala b/client/src/main/scala/org/scastie/client/Routing.scala index 40bba0c19..128e7ad3f 100644 --- a/client/src/main/scala/org/scastie/client/Routing.scala +++ b/client/src/main/scala/org/scastie/client/Routing.scala @@ -1,18 +1,18 @@ package org.scastie.client -import org.scastie.api._ -import org.scastie.client.components._ -import japgolly.scalajs.react._ -import vdom.all._ -import extra.router._ - import java.util.UUID +import extra.router._ import io.circe._ import io.circe.parser._ import io.circe.syntax._ +import japgolly.scalajs.react._ +import org.scastie.api._ +import org.scastie.client.components._ +import vdom.all._ class Routing(defaultServerUrl: String) { + val config: RouterConfig[Page] = RouterConfigDsl[Page].buildConfig { dsl => import dsl._ val embedded = "embedded" @@ -21,16 +21,16 @@ class Routing(defaultServerUrl: String) { val targetType = queryToMap.pmap { map => ( map.get("target"), - map.get("c"), + map.get("c") ) match { - case (Some(target), c) => - ScalaTargetType.parse(target.toUpperCase).map(target => TargetTypePage(target, c)) - case _ => None + case (Some(target), c) => ScalaTargetType.parse(target.toUpperCase).map(target => TargetTypePage(target, c)) + case _ => None } }(p => Map("target" -> p.targetType.toString) ++ p.code.map("c" -> _)) val inputs = queryToMap.pmap { map => - map.get("inputs") + map + .get("inputs") .flatMap(inputs => decode[BaseInputs](inputs).toOption) .map(inputs => InputsPage(inputs)) }(p => Map("inputs" -> p.inputs.asJson.noSpaces.replace("{", "%7B").replace("}", "%7D"))) @@ -42,17 +42,14 @@ class Routing(defaultServerUrl: String) { map.get("v"), map.get("o"), map.get("r"), - map.get("c"), + map.get("c") ) match { case (Some(g), Some(a), Some(v), o, r, c) => val target = map.get("t").flatMap(ScalaTargetType.parse) match { - case Some(t @ ScalaTargetType.Scala2) => - map.get("sv").map(sv => Scala2(ScalaVersions.find(t, sv))) - case Some(t @ ScalaTargetType.JS) => - (map.get("sv"), map.get("sjsv")) match { - case (Some(sv), sjsv) => - Some(Js(ScalaVersions.find(t, sv), sjsv.getOrElse(Js.default.scalaJsVersion))) - case _ => None + case Some(t @ ScalaTargetType.Scala2) => map.get("sv").map(sv => Scala2(ScalaVersions.find(t, sv))) + case Some(t @ ScalaTargetType.JS) => (map.get("sv"), map.get("sjsv")) match { + case (Some(sv), sjsv) => Some(Js(ScalaVersions.find(t, sv), sjsv.getOrElse(Js.default.scalaJsVersion))) + case _ => None } case _ => None } @@ -67,16 +64,16 @@ class Routing(defaultServerUrl: String) { def renderTryLibrary(dep: TryLibraryPage) = { val tm = dep.dependency.target match { - case Scala2(sv) => Map("sv" -> sv) + case Scala2(sv) => Map("sv" -> sv) case Js(sv, sjsv) => Map("sv" -> sv, "sjsv" -> sjsv) - case _ => Map[String, String]() + case _ => Map[String, String]() } tm ++ dep.code.map("c" -> _) ++ Map( "g" -> dep.dependency.groupId, "a" -> dep.dependency.artifact, "v" -> dep.dependency.version, "r" -> dep.project.repository, - "o" -> dep.project.organization, + "o" -> dep.project.organization ) } @@ -90,31 +87,31 @@ class Routing(defaultServerUrl: String) { ( trimSlashes | staticRoute(root, Home) ~> - renderR(renderScastieDefault) + renderR(renderScastieDefault) | dynamicRouteCT("try" ~ tryLibrary) ~> - dynRenderR((page, router) => renderTryLibraryPage(page, router)) + dynRenderR((page, router) => renderTryLibraryPage(page, router)) | dynamicRouteCT(inputs) ~> - dynRenderR((page, router) => renderInputs(page, router)) + dynRenderR((page, router) => renderInputs(page, router)) | dynamicRouteCT(targetType) ~> - dynRenderR((page, router) => renderTargetTypePage(page, router)) + dynRenderR((page, router) => renderTargetTypePage(page, router)) | dynamicRouteCT(oldId.caseClass[OldSnippetIdPage]) ~> - dynRenderR((page, router) => renderOldSnippetIdPage(page, router)) + dynRenderR((page, router) => renderOldSnippetIdPage(page, router)) | dynamicRouteCT(anon.caseClass[AnonymousResource]) ~> - dynRenderR((page, router) => renderPage(page, router)) + dynRenderR((page, router) => renderPage(page, router)) | dynamicRouteCT(user.caseClass[UserResource]) ~> - dynRenderR((page, router) => renderPage(page, router)) + dynRenderR((page, router) => renderPage(page, router)) | dynamicRouteCT(userUpdate.caseClass[UserResourceUpdated]) ~> - dynRenderR((page, router) => renderPage(page, router)) + dynRenderR((page, router) => renderPage(page, router)) | staticRoute(embedded, Embedded) ~> - renderR(renderScastieDefaultEmbedded) + renderR(renderScastieDefaultEmbedded) | dynamicRouteCT(embedded / anon.caseClass[EmbeddedAnonymousResource]) ~> - dynRenderR((page, router) => renderPage(page, router)) + dynRenderR((page, router) => renderPage(page, router)) | dynamicRouteCT(embedded / user.caseClass[EmbeddedUserResource]) ~> - dynRenderR((page, router) => renderPage(page, router)) + dynRenderR((page, router) => renderPage(page, router)) | dynamicRouteCT( embedded / userUpdate.caseClass[EmbeddedUserResourceUpdated] ) ~> - dynRenderR((page, router) => renderPage(page, router)) + dynRenderR((page, router) => renderPage(page, router)) ).notFound(redirectToPage(Home)(SetRouteVia.HistoryReplace)) .renderWith((page, router) => layout(page, router)) } @@ -141,12 +138,11 @@ class Routing(defaultServerUrl: String) { } private def renderScastieDefaultEmbedded( - router: RouterCtl[Page] - ): VdomElement = - Scastie - .default(router) - .copy(embedded = Some(EmbeddedOptions.empty(defaultServerUrl))) - .render + router: RouterCtl[Page] + ): VdomElement = Scastie + .default(router) + .copy(embedded = Some(EmbeddedOptions.empty(defaultServerUrl))) + .render private def renderPage(page: ResourcePage, router: RouterCtl[Page]): VdomElement = { val defaultEmbedded = Some(EmbeddedOptions.empty(defaultServerUrl)) @@ -181,7 +177,7 @@ class Routing(defaultServerUrl: String) { targetType = None, tryLibrary = None, code = None, - inputs = None, + inputs = None ).render } diff --git a/client/src/main/scala/org/scastie/client/RoutingADT.scala b/client/src/main/scala/org/scastie/client/RoutingADT.scala index 22603e38c..ca6fd2841 100644 --- a/client/src/main/scala/org/scastie/client/RoutingADT.scala +++ b/client/src/main/scala/org/scastie/client/RoutingADT.scala @@ -3,18 +3,17 @@ package org.scastie.client import org.scastie.api._ object Page { + def fromSnippetId(snippetId: SnippetId): ResourcePage = { snippetId match { - case SnippetId(uuid, None) => - AnonymousResource(uuid) + case SnippetId(uuid, None) => AnonymousResource(uuid) - case SnippetId(uuid, Some(SnippetUserPart(login, 0))) => - UserResource(login, uuid) + case SnippetId(uuid, Some(SnippetUserPart(login, 0))) => UserResource(login, uuid) - case SnippetId(uuid, Some(SnippetUserPart(login, update))) => - UserResourceUpdated(login, uuid, update) + case SnippetId(uuid, Some(SnippetUserPart(login, update))) => UserResourceUpdated(login, uuid, update) } } + } sealed trait Page diff --git a/client/src/main/scala/org/scastie/client/ScastieBackend.scala b/client/src/main/scala/org/scastie/client/ScastieBackend.scala index 15120ea74..b1f76e153 100644 --- a/client/src/main/scala/org/scastie/client/ScastieBackend.scala +++ b/client/src/main/scala/org/scastie/client/ScastieBackend.scala @@ -1,26 +1,24 @@ package org.scastie.client import java.util.UUID +import scala.concurrent.duration._ +import scala.concurrent.Future +import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue -import org.scastie.api._ -import org.scastie.client.components.Scastie import japgolly.scalajs.react._ import japgolly.scalajs.react.component.Scala.BackendScope import japgolly.scalajs.react.extra._ import japgolly.scalajs.react.util.Effect.Id import org.scalajs.dom.{Position => _, _} - -import scala.concurrent.Future -import scala.concurrent.duration._ -import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue -import org.scastie.runtime.api.RuntimeError +import org.scastie.api._ import org.scastie.client.components.ScaladexSearch +import org.scastie.client.components.Scastie import org.scastie.client.scalacli.ScalaCliUtils._ +import org.scastie.runtime.api.RuntimeError case class ScastieBackend(scastieId: UUID, serverUrl: Option[String], scope: BackendScope[Scastie, ScastieState]) { - private val restApiClient = - new RestApiClient(serverUrl) + private val restApiClient = new RestApiClient(serverUrl) // XXX: This should not be global Global.subscribe(scope, scastieId) @@ -29,145 +27,110 @@ case class ScastieBackend(scastieId: UUID, serverUrl: Option[String], scope: Bac Callback(Global.subscribe(scope, scastieId)) } - val codeChange: String ~=> Callback = - Reusable.fn(code => { - scope.modState(state => { - val newState = state.setCode(code) - newState - }) + val codeChange: String ~=> Callback = Reusable.fn(code => { + scope.modState(state => { + val newState = state.setCode(code) + newState }) + }) val sbtConfigChange: String ~=> Callback = { Reusable.fn(newConfig => scope.modState(_.setSbtConfigExtra(newConfig))) } - val resetBuild: Reusable[Callback] = - Reusable.always { - val setData = scope.state.map( - state => { - state - .setInputs(SbtInputs.default.copyBaseInput(code = state.inputs.code)) - .clearOutputs - .clearSnippetId - .setChangedInputs - } - ) + val resetBuild: Reusable[Callback] = Reusable.always { + val setData = scope.state.map(state => { + state + .setInputs(SbtInputs.default.copyBaseInput(code = state.inputs.code)) + .clearOutputs + .clearSnippetId + .setChangedInputs + }) - setData >> setHome - } + setData >> setHome + } - val newSnippet: Reusable[Callback] = - Reusable.always { - val setData = scope.state.map(state => { - state - .copy(isDesktopForced = false) - .setInputs(SbtInputs.default.copyBaseInput(code = "")) - .clearOutputs - .clearSnippetId - .setChangedInputs - }) - - setData >> setHome - } + val newSnippet: Reusable[Callback] = Reusable.always { + val setData = scope.state.map(state => { + state + .copy(isDesktopForced = false) + .setInputs(SbtInputs.default.copyBaseInput(code = "")) + .clearOutputs + .clearSnippetId + .setChangedInputs + }) + + setData >> setHome + } val clear: Reusable[Callback] = Reusable.always(scope.modState(_.clearOutputsPreserveConsole) >> scope.modState(_.closeModals)) - private def clearOutputs: Callback = - scope.modState(_.clearOutputs) + private def clearOutputs: Callback = scope.modState(_.clearOutputs) - def clearCode: Callback = - scope.modState(_.setCode("")) + def clearCode: Callback = scope.modState(_.setCode("")) - val setLanguage: String ~=> Callback = - Reusable.fn(language => scope.modState(_.setLanguage(language))) + val setLanguage: String ~=> Callback = Reusable.fn(language => scope.modState(_.setLanguage(language))) - val setViewReused: View ~=> Callback = - Reusable.fn(setView _) + val setViewReused: View ~=> Callback = Reusable.fn(setView _) - def setView(newView: View): Callback = - scope.modState(_.setView(newView)) + def setView(newView: View): Callback = scope.modState(_.setView(newView)) val viewSnapshot: StateSnapshot.withReuse.FromSetStateFn[View] = StateSnapshot.withReuse.prepare((opts, c) => opts.fold(c)(setView)) - val setTarget: ScalaTarget ~=> Callback = - Reusable.fn(target => scope.modState(_.setTarget(target))) + val setTarget: ScalaTarget ~=> Callback = Reusable.fn(target => scope.modState(_.setTarget(target))) - val addScalaDependency: (ScalaDependency, Project) ~=> Callback = - Reusable.fn { - case (scalaDependency, project) => - scope.modState(_.addScalaDependency(scalaDependency, project)) - } + val addScalaDependency: (ScalaDependency, Project) ~=> Callback = Reusable.fn { case (scalaDependency, project) => + scope.modState(_.addScalaDependency(scalaDependency, project)) + } val removeScalaDependency: ScalaDependency ~=> Callback = - Reusable.fn( - scalaDependency => scope.modState(_.removeScalaDependency(scalaDependency)) - ) + Reusable.fn(scalaDependency => scope.modState(_.removeScalaDependency(scalaDependency))) - val updateDependencyVersion: (ScalaDependency, String) ~=> Callback = - Reusable.fn { - case (scalaDependency, version) => - scope.modState(_.updateDependencyVersion(scalaDependency, version)) - } + val updateDependencyVersion: (ScalaDependency, String) ~=> Callback = Reusable.fn { case (scalaDependency, version) => + scope.modState(_.updateDependencyVersion(scalaDependency, version)) + } - val toggleTheme: Reusable[Callback] = - Reusable.always(scope.modState(_.toggleTheme)) + val toggleTheme: Reusable[Callback] = Reusable.always(scope.modState(_.toggleTheme)) - val setEditorMode: EditorMode ~=> Callback = - Reusable.fn(mode => scope.modState(_.setEditorMode(mode))) + val setEditorMode: EditorMode ~=> Callback = Reusable.fn(mode => scope.modState(_.setEditorMode(mode))) - val setMetalsStatus: MetalsStatus ~=> Callback = - Reusable.fn(status => scope.modState(_.setMetalsStatus(status))) + val setMetalsStatus: MetalsStatus ~=> Callback = Reusable.fn(status => scope.modState(_.setMetalsStatus(status))) val updateSettings: ScastieMetalsOptions ~=> Callback = Reusable.fn(newSettings => scope.modState(_.updateScalaCliSettings(newSettings))) - val toggleMetalsStatus: Reusable[Callback] = - Reusable.always(scope.modState(_.toggleMetalsStatus)) + val toggleMetalsStatus: Reusable[Callback] = Reusable.always(scope.modState(_.toggleMetalsStatus)) - val toggleLineNumbers: Reusable[Callback] = - Reusable.always(scope.modState(_.toggleLineNumbers)) + val toggleLineNumbers: Reusable[Callback] = Reusable.always(scope.modState(_.toggleLineNumbers)) - val togglePresentationMode: Reusable[Callback] = - Reusable.always(scope.modState(_.togglePresentationMode)) + val togglePresentationMode: Reusable[Callback] = Reusable.always(scope.modState(_.togglePresentationMode)) - val openConsole: Reusable[Callback] = - Reusable.always(scope.modState(_.openConsole)) + val openConsole: Reusable[Callback] = Reusable.always(scope.modState(_.openConsole)) - val closeConsole: Reusable[Callback] = - Reusable.always(scope.modState(_.closeConsole)) + val closeConsole: Reusable[Callback] = Reusable.always(scope.modState(_.closeConsole)) - val toggleConsole: Reusable[Callback] = - Reusable.always(scope.modState(_.toggleConsole)) + val toggleConsole: Reusable[Callback] = Reusable.always(scope.modState(_.toggleConsole)) - val openResetModal: Reusable[Callback] = - Reusable.always(scope.modState(_.openResetModal)) + val openResetModal: Reusable[Callback] = Reusable.always(scope.modState(_.openResetModal)) - val closeResetModal: Reusable[Callback] = - Reusable.always(scope.modState(_.closeResetModal)) + val closeResetModal: Reusable[Callback] = Reusable.always(scope.modState(_.closeResetModal)) - val openNewSnippetModal: Reusable[Callback] = - Reusable.always(scope.modState(_.openNewSnippetModal)) + val openNewSnippetModal: Reusable[Callback] = Reusable.always(scope.modState(_.openNewSnippetModal)) // ok - private def closeNewSnippetModal0: Callback = - scope.modState(_.closeNewSnippetModal) + private def closeNewSnippetModal0: Callback = scope.modState(_.closeNewSnippetModal) - val closeNewSnippetModal: Reusable[Callback] = - Reusable.always(closeNewSnippetModal0) + val closeNewSnippetModal: Reusable[Callback] = Reusable.always(closeNewSnippetModal0) - val openHelpModal: Reusable[Callback] = - Reusable.always(scope.modState(_.openHelpModal)) + val openHelpModal: Reusable[Callback] = Reusable.always(scope.modState(_.openHelpModal)) - val openPrivacyPolicyModal: Reusable[Callback] = - Reusable.always(scope.modState(_.openPrivacyPolicyModal)) + val openPrivacyPolicyModal: Reusable[Callback] = Reusable.always(scope.modState(_.openPrivacyPolicyModal)) - val closeHelpModal: Reusable[Callback] = - Reusable.always(scope.modState(_.toggleHelpModal)) + val closeHelpModal: Reusable[Callback] = Reusable.always(scope.modState(_.toggleHelpModal)) - val closePrivacyPolicyModal: Reusable[Callback] = - Reusable.always(scope.modState(_.togglePrivacyPolicyModal)) + val closePrivacyPolicyModal: Reusable[Callback] = Reusable.always(scope.modState(_.togglePrivacyPolicyModal)) val closePrivacyPolicyPrompt: Reusable[Callback] = Reusable.always(scope.modState(_.setPrivacyPolicyPromptClosed(true))) @@ -175,17 +138,13 @@ case class ScastieBackend(scastieId: UUID, serverUrl: Option[String], scope: Bac val openPrivacyPolicyPrompt: Reusable[Callback] = Reusable.always(scope.modState(_.setPrivacyPolicyPromptClosed(false))) - val openLoginModal: Reusable[Callback] = - Reusable.always(scope.modState(_.setLoginModalClosed(false))) + val openLoginModal: Reusable[Callback] = Reusable.always(scope.modState(_.setLoginModalClosed(false))) - val closeLoginModal: Reusable[Callback] = - Reusable.always(scope.modState(_.setLoginModalClosed(true))) + val closeLoginModal: Reusable[Callback] = Reusable.always(scope.modState(_.setLoginModalClosed(true))) - val toggleHelpModal: Reusable[Callback] = - Reusable.always(scope.modState(_.toggleHelpModal)) + val toggleHelpModal: Reusable[Callback] = Reusable.always(scope.modState(_.toggleHelpModal)) - val closeShareModal: Reusable[Callback] = - Reusable.always(scope.modState(_.closeShareModal)) + val closeShareModal: Reusable[Callback] = Reusable.always(scope.modState(_.closeShareModal)) val openShareModalOption: Option[SnippetId] ~=> Callback = Reusable.fn(snippetId => scope.modState(_.openShareModal(snippetId))) @@ -193,17 +152,13 @@ case class ScastieBackend(scastieId: UUID, serverUrl: Option[String], scope: Bac val openShareModal: SnippetId ~=> Callback = Reusable.fn(snippetId => scope.modState(_.openShareModal(Some(snippetId)))) - val openEmbeddedModal: Reusable[Callback] = - Reusable.always(scope.modState(_.openEmbeddedModal)) + val openEmbeddedModal: Reusable[Callback] = Reusable.always(scope.modState(_.openEmbeddedModal)) - val closeEmbeddedModal: Reusable[Callback] = - Reusable.always(scope.modState(_.closeEmbeddedModal)) + val closeEmbeddedModal: Reusable[Callback] = Reusable.always(scope.modState(_.closeEmbeddedModal)) - val forceDesktop: Reusable[Callback] = - Reusable.always(scope.modState(_.forceDesktop)) + val forceDesktop: Reusable[Callback] = Reusable.always(scope.modState(_.forceDesktop)) - val toggleWorksheetMode: Reusable[Callback] = - Reusable.always(unlessEmbedded(_.toggleWorksheetMode)) + val toggleWorksheetMode: Reusable[Callback] = Reusable.always(unlessEmbedded(_.toggleWorksheetMode)) private def unlessEmbedded(f: ScastieState => ScastieState): Callback = { scope.props @@ -212,48 +167,44 @@ case class ScastieBackend(scastieId: UUID, serverUrl: Option[String], scope: Bac } private def connectProgress(snippetId: SnippetId): Callback = - scope.state.map(_.inputs.target.targetType).flatMap { scalaTargetType => - val apiBase = serverUrl.getOrElse("") - val targetType = if (scalaTargetType == ScalaTargetType.ScalaCli) "Scala-CLI" else "sbt" - - EventStream.connect( - eventSourceUri = s"$apiBase/api/progress-sse/${snippetId.url}", - websocketUri = s"$apiBase/api/progress-ws/${snippetId.url}", - handler = new EventStreamHandler[SnippetProgress] { - val direct: scope.WithEffect[Id] = scope.withEffectsImpure - - def onMessage(progress: SnippetProgress): Boolean = { - direct.modState(_.addProgress(progress)) - progress.isDone - } + scope.state.map(_.inputs.target.targetType).flatMap { scalaTargetType => + val apiBase = serverUrl.getOrElse("") + val targetType = if (scalaTargetType == ScalaTargetType.ScalaCli) "Scala-CLI" else "sbt" + + EventStream.connect( + eventSourceUri = s"$apiBase/api/progress-sse/${snippetId.url}", + websocketUri = s"$apiBase/api/progress-ws/${snippetId.url}", + handler = new EventStreamHandler[SnippetProgress] { + val direct: scope.WithEffect[Id] = scope.withEffectsImpure + + def onMessage(progress: SnippetProgress): Boolean = { + direct.modState(_.addProgress(progress)) + progress.isDone + } - def onOpen(): Unit = - direct.modState(_.logSystem(s"Connected. Waiting for $targetType")) + def onOpen(): Unit = direct.modState(_.logSystem(s"Connected. Waiting for $targetType")) - def onError(error: String): Unit = - direct.modState(_.logSystem(s"Error: $error")) + def onError(error: String): Unit = direct.modState(_.logSystem(s"Error: $error")) - def onClose(reason: Option[String]): Unit = { - val msg = reason.map(": " + _).getOrElse(".") - direct.modState( - _.copy( - isRunning = false, - progressStream = None - ).logSystem("Closed" + msg) - ) - } + def onClose(reason: Option[String]): Unit = { + val msg = reason.map(": " + _).getOrElse(".") + direct.modState( + _.copy( + isRunning = false, + progressStream = None + ).logSystem("Closed" + msg) + ) + } - def onConnectionError(error: String): Callback = - scope.modState(_.logSystem(s"Error: $error")) + def onConnectionError(error: String): Callback = scope.modState(_.logSystem(s"Error: $error")) - def onConnected(stream: EventStream[SnippetProgress]): Callback = - scope.modState( + def onConnected(stream: EventStream[SnippetProgress]): Callback = scope.modState( _.run(snippetId) .copy(progressStream = Some(stream)) ) - } - ) - } + } + ) + } def disconnectStatus: Callback = { scope.state.map( @@ -303,44 +254,41 @@ case class ScastieBackend(scastieId: UUID, serverUrl: Option[String], scope: Bac ) } - val run: Reusable[Callback] = - Reusable.always( - scope.state.flatMap( - state => - Callback.future( - restApiClient - .run(state.inputs) - .map(connectProgress) - ) + val run: Reusable[Callback] = Reusable.always( + scope.state.flatMap(state => + Callback.future( + restApiClient + .run(state.inputs) + .map(connectProgress) ) ) + ) - val acceptPolicy: Reusable[Callback] = - Reusable.always( - Callback.future { - restApiClient.acceptPrivacyPolicy().map { result => - scope.modState(_.setPrivacyPolicyPromptClosed(result)) - } + val acceptPolicy: Reusable[Callback] = Reusable.always( + Callback.future { + restApiClient.acceptPrivacyPolicy().map { result => + scope.modState(_.setPrivacyPolicyPromptClosed(result)) } - ) + } + ) - val removeUserFromPolicyStatus: Reusable[Callback] = - Reusable.always( - Callback.future { - restApiClient.removeUserFromPolicyStatus().map { result => - scope.modState(_.setPrivacyPolicyPromptClosed(result)).map(_ => { + val removeUserFromPolicyStatus: Reusable[Callback] = Reusable.always( + Callback.future { + restApiClient.removeUserFromPolicyStatus().map { result => + scope + .modState(_.setPrivacyPolicyPromptClosed(result)) + .map(_ => { if (result) document.location.reload() }) - } } - ) + } + ) - val removeAllUserSnippets: Reusable[Callback] = - Reusable.always( - Callback.future { - restApiClient.removeAllUserSnippets().map(Callback(_)) - } - ) + val removeAllUserSnippets: Reusable[Callback] = Reusable.always( + Callback.future { + restApiClient.removeAllUserSnippets().map(Callback(_)) + } + ) val refusePrivacyPolicy: Reusable[Callback] = Reusable.always( removeAllUserSnippets >> removeUserFromPolicyStatus @@ -361,56 +309,52 @@ case class ScastieBackend(scastieId: UUID, serverUrl: Option[String], scope: Bac } } - val saveBlocking: Reusable[CallbackTo[Option[SnippetId]]] = - Reusable.always( - scope.state.map(state => restApiClient.saveBlocking(state.inputs)) - ) + val saveBlocking: Reusable[CallbackTo[Option[SnippetId]]] = Reusable.always( + scope.state.map(state => restApiClient.saveBlocking(state.inputs)) + ) - val saveOrUpdate: Reusable[Callback] = - Reusable.always( - scope.props.flatMap { props => - scope.state - .flatMap { state => - if (props.isEmbedded) { - run - } else { - state.snippetId match { - case Some(snippetId) => - if (snippetId.isOwnedBy(state.user)) { - update0(snippetId) - } else { - fork0(snippetId) - } - case None => save0 - } + val saveOrUpdate: Reusable[Callback] = Reusable.always( + scope.props.flatMap { props => + scope.state + .flatMap { state => + if (props.isEmbedded) { + run + } else { + state.snippetId match { + case Some(snippetId) => + if (snippetId.isOwnedBy(state.user)) { + update0(snippetId) + } else { + fork0(snippetId) + } + case None => save0 } } - } - ) - - private def fork0(snippetId: SnippetId): Callback = - scope.state.flatMap { state => - Callback.future( - restApiClient - .fork(EditInputs(snippetId, state.inputs)) - .map { - case Some(sId) => saveCallback(sId) - case None => Callback(window.alert("Failed to fork")) - } - ) + } } + ) - private def update0(snippetId: SnippetId): Callback = - scope.state.flatMap { state => - Callback.future( - restApiClient - .update(EditInputs(snippetId, state.inputs)) - .map { - case Some(sId) => saveCallback(sId) - case None => Callback(window.alert("Failed to update")) - } - ) - } + private def fork0(snippetId: SnippetId): Callback = scope.state.flatMap { state => + Callback.future( + restApiClient + .fork(EditInputs(snippetId, state.inputs)) + .map { + case Some(sId) => saveCallback(sId) + case None => Callback(window.alert("Failed to fork")) + } + ) + } + + private def update0(snippetId: SnippetId): Callback = scope.state.flatMap { state => + Callback.future( + restApiClient + .update(EditInputs(snippetId, state.inputs)) + .map { + case Some(sId) => saveCallback(sId) + case None => Callback(window.alert("Failed to update")) + } + ) + } def loadOldSnippet(id: Int): Callback = { loadSnippetBase( @@ -427,34 +371,31 @@ case class ScastieBackend(scastieId: UUID, serverUrl: Option[String], scope: Bac } private def loadSnippetBase( - fetchSnippet: => Future[Option[FetchResult]], - afterLoading: ScastieState => ScastieState = identity, - snippetId: Option[SnippetId] = None + fetchSnippet: => Future[Option[FetchResult]], + afterLoading: ScastieState => ScastieState = identity, + snippetId: Option[SnippetId] = None ): Callback = { scope.state.flatMap { state => if (state.loadSnippet) { - val loadStateFromApi = - Callback.future( - fetchSnippet.map { - case Some(FetchResult(inputs, progresses)) => - val isDone = progresses.exists(_.isDone) - val connect = - snippetId match { - case Some(sid) if !isDone => connectProgress(sid) - case _ => Callback(()) - } - clearOutputs >> scope.modState { state => - afterLoading( - state - .setInputs(inputs) - .setProgresses(progresses) - .setCleanInputs - ) - } >> connect - case _ => - scope.modState(_.setCode(s"//snippet not found")) - } - ) + val loadStateFromApi = Callback.future( + fetchSnippet.map { + case Some(FetchResult(inputs, progresses)) => + val isDone = progresses.exists(_.isDone) + val connect = snippetId match { + case Some(sid) if !isDone => connectProgress(sid) + case _ => Callback(()) + } + clearOutputs >> scope.modState { state => + afterLoading( + state + .setInputs(inputs) + .setProgresses(progresses) + .setCleanInputs + ) + } >> connect + case _ => scope.modState(_.setCode(s"//snippet not found")) + } + ) loadStateFromApi >> setView(View.Editor) >> @@ -467,44 +408,40 @@ case class ScastieBackend(scastieId: UUID, serverUrl: Option[String], scope: Bac } } - def loadUser: Callback = - Callback.future( - restApiClient - .fetchUser() - .map(result => scope.modState(_.setUser(result))) - ) >> Callback.future( - restApiClient - .getPrivacyPolicyStatus() - .map(result => scope.modState(_.setPrivacyPolicyPromptClosed(result))) - ) + def loadUser: Callback = Callback.future( + restApiClient + .fetchUser() + .map(result => scope.modState(_.setUser(result))) + ) >> Callback.future( + restApiClient + .getPrivacyPolicyStatus() + .map(result => scope.modState(_.setPrivacyPolicyPromptClosed(result))) + ) val formatCode: Reusable[Callback] = Reusable.always { scope.state.flatMap { state => Callback.future { restApiClient .format(FormatRequest(state.inputs.code, state.inputs.isWorksheetMode, state.inputs.target)) - .map { - case FormatResponse(formattedCode) => - scope.modState { s => - // avoid overriding user's code if he/she types while it's formatting - if (s.inputs.code == state.inputs.code) - s.clearOutputsPreserveConsole.setCode(formattedCode) - else s - } + .map { case FormatResponse(formattedCode) => + scope.modState { s => + // avoid overriding user's code if he/she types while it's formatting + if (s.inputs.code == state.inputs.code) s.clearOutputsPreserveConsole.setCode(formattedCode) + else s + } } } } } - val loadProfile: Reusable[Future[List[SnippetSummary]]] = - Reusable.always(restApiClient.fetchUserSnippets()) + val loadProfile: Reusable[Future[List[SnippetSummary]]] = Reusable.always(restApiClient.fetchUserSnippets()) - val deleteSnippet: SnippetId ~=> Future[Boolean] = - Reusable.always(snippetId => restApiClient.delete(snippetId)) + val deleteSnippet: SnippetId ~=> Future[Boolean] = Reusable.always(snippetId => restApiClient.delete(snippetId)) private def setHome = scope.props.flatMap( _.router .map(_.set(Home)) .getOrElse(Callback.empty) ) + } diff --git a/client/src/main/scala/org/scastie/client/ScastieState.scala b/client/src/main/scala/org/scastie/client/ScastieState.scala index 4b18a915c..e907d9dee 100644 --- a/client/src/main/scala/org/scastie/client/ScastieState.scala +++ b/client/src/main/scala/org/scastie/client/ScastieState.scala @@ -46,9 +46,9 @@ object SnippetState { } case class SnippetState( - snippetId: Option[SnippetId], - loadSnippet: Boolean, - scalaJsContent: Option[String] + snippetId: Option[SnippetId], + loadSnippet: Boolean, + scalaJsContent: Option[String] ) object ScastieState { @@ -103,32 +103,32 @@ object ScastieState { } case class ScastieState( - view: View, - isRunning: Boolean, - statusStream: Option[EventStream[StatusProgress]], - progressStream: Option[EventStream[SnippetProgress]], - modalState: ModalState, - isDarkTheme: Boolean, - isDesktopForced: Boolean, - isPresentationMode: Boolean, - showLineNumbers: Boolean, - consoleState: ConsoleState, - inputsHasChanged: Boolean, - snippetState: SnippetState, - user: Option[User], - attachedDoms: Map[String, HTMLElement], - inputs: BaseInputs, - outputs: Outputs, - status: StatusState, - metalsStatus: MetalsStatus = MetalsLoading, - isEmbedded: Boolean = false, - transient: Boolean = false, - scalaCliConversionError: Option[String] = None, - editorMode: EditorMode = Default, - language: String = "en" + view: View, + isRunning: Boolean, + statusStream: Option[EventStream[StatusProgress]], + progressStream: Option[EventStream[SnippetProgress]], + modalState: ModalState, + isDarkTheme: Boolean, + isDesktopForced: Boolean, + isPresentationMode: Boolean, + showLineNumbers: Boolean, + consoleState: ConsoleState, + inputsHasChanged: Boolean, + snippetState: SnippetState, + user: Option[User], + attachedDoms: Map[String, HTMLElement], + inputs: BaseInputs, + outputs: Outputs, + status: StatusState, + metalsStatus: MetalsStatus = MetalsLoading, + isEmbedded: Boolean = false, + transient: Boolean = false, + scalaCliConversionError: Option[String] = None, + editorMode: EditorMode = Default, + language: String = "en" ) { def snippetId: Option[SnippetId] = snippetState.snippetId - def loadSnippet: Boolean = snippetState.loadSnippet + def loadSnippet: Boolean = snippetState.loadSnippet def copyAndSave( attachedDoms: Map[String, HTMLElement] = attachedDoms, @@ -241,9 +241,9 @@ case class ScastieState( def setEditorMode(mode: EditorMode): ScastieState = copyAndSave(editorMode = mode) def setLanguage(lang: String): ScastieState = { - I18n.setLanguage(lang) - copyAndSave(language = lang) - } + I18n.setLanguage(lang) + copyAndSave(language = lang) + } def setMetalsStatus(status: MetalsStatus): ScastieState = copyAndSave(metalsStatus = status) @@ -458,9 +458,7 @@ case class ScastieState( } def setProgresses(progresses: List[SnippetProgress]): ScastieState = coalesceUpdates { self => - progresses.foldLeft(self) { case (state, progress) => - state.addProgress(progress) - } + progresses.foldLeft(self) { case (state, progress) => state.addProgress(progress) } } def setSnippetId(snippetId: SnippetId): ScastieState = copyAndSave(snippetId = Some(snippetId)) diff --git a/client/src/main/scala/org/scastie/client/Views.scala b/client/src/main/scala/org/scastie/client/Views.scala index b7a7acac6..f9615ece0 100644 --- a/client/src/main/scala/org/scastie/client/Views.scala +++ b/client/src/main/scala/org/scastie/client/Views.scala @@ -4,6 +4,7 @@ import io.circe._ import io.circe.generic.semiauto._ sealed trait View + object View { case object Editor extends View case object BuildSettings extends View diff --git a/client/src/main/scala/org/scastie/client/components/BuildSettings.scala b/client/src/main/scala/org/scastie/client/components/BuildSettings.scala index 656f09178..4fc4282cc 100644 --- a/client/src/main/scala/org/scastie/client/components/BuildSettings.scala +++ b/client/src/main/scala/org/scastie/client/components/BuildSettings.scala @@ -9,27 +9,28 @@ import org.scastie.client.i18n.I18n import vdom.all._ final case class BuildSettings( - visible: Boolean, - inputs: BaseInputs, - isDarkTheme: Boolean, - isBuildDefault: Boolean, - isResetModalClosed: Boolean, - setTarget: ScalaTarget ~=> Callback, - closeResetModal: Reusable[Callback], - resetBuild: Reusable[Callback], - openResetModal: Reusable[Callback], - sbtConfigChange: String ~=> Callback, - removeScalaDependency: ScalaDependency ~=> Callback, - updateDependencyVersion: (ScalaDependency, String) ~=> Callback, - addScalaDependency: (ScalaDependency, Project) ~=> Callback, - scalaCliConversionError: Option[String], - language: String + visible: Boolean, + inputs: BaseInputs, + isDarkTheme: Boolean, + isBuildDefault: Boolean, + isResetModalClosed: Boolean, + setTarget: ScalaTarget ~=> Callback, + closeResetModal: Reusable[Callback], + resetBuild: Reusable[Callback], + openResetModal: Reusable[Callback], + sbtConfigChange: String ~=> Callback, + removeScalaDependency: ScalaDependency ~=> Callback, + updateDependencyVersion: (ScalaDependency, String) ~=> Callback, + addScalaDependency: (ScalaDependency, Project) ~=> Callback, + scalaCliConversionError: Option[String], + language: String ) { @inline def render: VdomElement = BuildSettings.component(this) } object BuildSettings { + private def renderWithElement(template: String, elementBuilder: String => VdomElement): VdomElement = { val elementRegex = """\{([^}]+)\}""".r elementRegex.findFirstMatchIn(template) match { @@ -39,10 +40,10 @@ object BuildSettings { val element = elementBuilder(elementContent) val after = template.substring(m.end) span(before, element, after) - case None => - span(template) + case None => span(template) } } + var mutableList: List[(ScalaDependency, Project)] = List.empty[(ScalaDependency, Project)] implicit val reusability: Reusability[BuildSettings] = Reusability.derive[BuildSettings] @@ -61,10 +62,10 @@ object BuildSettings { ).render, div( hidden := props.isBuildDefault || props.inputs.target.targetType == ScalaTargetType.ScalaCli, - title := I18n.t("build.reset_tooltip"), + title := I18n.t("build.reset_tooltip"), onClick --> props.openResetModal, role := "button", - cls := "btn" + cls := "btn" )( I18n.t("build.reset") ) @@ -148,13 +149,19 @@ object BuildSettings { p( renderWithElement( I18n.t("build.scala_cli_version_doc"), - content => a(href := "https://scala-cli.virtuslab.org/docs/reference/directives/#scala-version", target := "_blank")(content) + content => + a(href := "https://scala-cli.virtuslab.org/docs/reference/directives/#scala-version", target := "_blank")( + content + ) ) ), p( renderWithElement( I18n.t("build.scala_cli_dependency_doc"), - content => a(href := "https://scala-cli.virtuslab.org/docs/reference/directives#dependency", target := "_blank")(content) + content => + a(href := "https://scala-cli.virtuslab.org/docs/reference/directives#dependency", target := "_blank")( + content + ) ) ) ) diff --git a/client/src/main/scala/org/scastie/client/components/ClearButton.scala b/client/src/main/scala/org/scastie/client/components/ClearButton.scala index d699b563a..048fdacb7 100644 --- a/client/src/main/scala/org/scastie/client/components/ClearButton.scala +++ b/client/src/main/scala/org/scastie/client/components/ClearButton.scala @@ -1,12 +1,10 @@ package org.scastie.client package components -import org.scastie.client.components.editor.EditorKeymaps import japgolly.scalajs.react._ - -import vdom.all._ - +import org.scastie.client.components.editor.EditorKeymaps import org.scastie.client.i18n.I18n +import vdom.all._ final case class ClearButton(clear: Reusable[Callback], language: String) { @inline def render: VdomElement = ClearButton.component(this) @@ -14,8 +12,7 @@ final case class ClearButton(clear: Reusable[Callback], language: String) { object ClearButton { - implicit val reusability: Reusability[ClearButton] = - Reusability.derive[ClearButton] + implicit val reusability: Reusability[ClearButton] = Reusability.derive[ClearButton] private def render(props: ClearButton): VdomElement = { li( @@ -31,10 +28,10 @@ object ClearButton { ) } - private val component = - ScalaComponent - .builder[ClearButton]("ClearButton") - .render_P(render) - .configure(Reusability.shouldComponentUpdate) - .build + private val component = ScalaComponent + .builder[ClearButton]("ClearButton") + .render_P(render) + .configure(Reusability.shouldComponentUpdate) + .build + } diff --git a/client/src/main/scala/org/scastie/client/components/CodeSnippets.scala b/client/src/main/scala/org/scastie/client/components/CodeSnippets.scala index 5a4020ac5..fb4d4f4c0 100644 --- a/client/src/main/scala/org/scastie/client/components/CodeSnippets.scala +++ b/client/src/main/scala/org/scastie/client/components/CodeSnippets.scala @@ -1,18 +1,16 @@ package org.scastie.client.components -import org.scastie.api._ -import org.scastie.client.Page -import org.scastie.client.View -import japgolly.scalajs.react._ -import japgolly.scalajs.react.component.builder.Lifecycle.RenderScope - import scala.concurrent.Future -import vdom.all._ import extra.router._ -import scalajs.concurrent.JSExecutionContext.Implicits.queue - +import japgolly.scalajs.react._ +import japgolly.scalajs.react.component.builder.Lifecycle.RenderScope +import org.scastie.api._ import org.scastie.client.i18n.I18n +import org.scastie.client.Page +import org.scastie.client.View +import scalajs.concurrent.JSExecutionContext.Implicits.queue +import vdom.all._ final case class CodeSnippets( view: View, @@ -30,47 +28,42 @@ final case class CodeSnippets( } object CodeSnippets { - implicit val reusability: Reusability[CodeSnippets] = - Reusability.derive[CodeSnippets] + implicit val reusability: Reusability[CodeSnippets] = Reusability.derive[CodeSnippets] private[CodeSnippets] class CodeSnippetsBackend( scope: BackendScope[CodeSnippets, List[SnippetSummary]] ) { def loadProfile0(): Callback = { - scope.props.flatMap( - props => - Callback.future( - props.loadProfile.map( - _.map(summaries => scope.modState(_ => summaries)) - ) + scope.props.flatMap(props => + Callback.future( + props.loadProfile.map( + _.map(summaries => scope.modState(_ => summaries)) + ) ) ) } def deleteSnippet0(summary: SnippetSummary): Callback = { - scope.props.flatMap( - props => - Callback.future( - props - .deleteSnippet(summary.snippetId) - .map( - deleted => scope.modState(_.filterNot(_ == summary)).when_(deleted) - ) + scope.props.flatMap(props => + Callback.future( + props + .deleteSnippet(summary.snippetId) + .map(deleted => scope.modState(_.filterNot(_ == summary)).when_(deleted)) ) ) } + } private def renderSnippet(backend: CodeSnippetsBackend, props: CodeSnippets)( - summary: SnippetSummary + summary: SnippetSummary ): VdomElement = { val page = Page.fromSnippetId(summary.snippetId) val update = summary.snippetId.user.map(_.update.toString).getOrElse("") - val snippetUrl = - props.router.urlFor(Page.fromSnippetId(summary.snippetId)).value + val snippetUrl = props.router.urlFor(Page.fromSnippetId(summary.snippetId)).value div(cls := "snippet")( CopyModal( @@ -87,7 +80,12 @@ object CodeSnippets { div(cls := "clear-mobile"), span(cls := "update", I18n.t("snippets.update") + update), div(cls := "actions")( - li(onClick --> props.openShareModal(summary.snippetId), cls := "btn", title := I18n.t("snippets.share"), role := "button")( + li( + onClick --> props.openShareModal(summary.snippetId), + cls := "btn", + title := I18n.t("snippets.share"), + role := "button" + )( i(cls := "fa fa-share-alt") ), li( @@ -110,19 +108,18 @@ object CodeSnippets { } private def render( - scope: RenderScope[ - CodeSnippets, - List[SnippetSummary], - CodeSnippetsBackend - ], - props: CodeSnippets, - summaries: List[SnippetSummary] + scope: RenderScope[ + CodeSnippets, + List[SnippetSummary], + CodeSnippetsBackend + ], + props: CodeSnippets, + summaries: List[SnippetSummary] ): VdomElement = { - val userAvatar = - div(cls := "avatar")( - img(src := props.user.avatar_url + "&s=70", alt := "Your Github Avatar", cls := "image-button avatar") - ) + val userAvatar = div(cls := "avatar")( + img(src := props.user.avatar_url + "&s=70", alt := "Your Github Avatar", cls := "image-button avatar") + ) val userName = props.user.name.getOrElse("") val userLogin = props.user.login @@ -135,11 +132,10 @@ object CodeSnippets { xs.groupBy(_.snippetId.base64UUID) .toList - .flatMap { - case (_, snippets) => - List( - snippets.sortBy(_.snippetId.user.map(_.update).getOrElse(0)).last - ) + .flatMap { case (_, snippets) => + List( + snippets.sortBy(_.snippetId.user.map(_.update).getOrElse(0)).last + ) } .sortBy(_.time) .reverse @@ -156,10 +152,9 @@ object CodeSnippets { div(cls := "snippets")( noSummaries, sortSnippets(summaries) - .map( - summary => - div(cls := "group", key := summary.snippetId.base64UUID)( - renderSnippet(scope.backend, props)(summary) + .map(summary => + div(cls := "group", key := summary.snippetId.base64UUID)( + renderSnippet(scope.backend, props)(summary) ) ) .toTagMod @@ -167,23 +162,21 @@ object CodeSnippets { ) } - private val component = - ScalaComponent - .builder[CodeSnippets]("CodeSnippets") - .initialState(List.empty[SnippetSummary]) - .backend(new CodeSnippetsBackend(_)) - .renderPS(render) - .componentWillReceiveProps { delta => - val viewChangedToCodeSnippet = - delta.currentProps.view != View.CodeSnippets && - delta.nextProps.view == View.CodeSnippets - - val loadProfile: Callback = - delta.backend.loadProfile0() - - loadProfile.when_(viewChangedToCodeSnippet) - } - .componentWillMount(_.backend.loadProfile0()) - .configure(Reusability.shouldComponentUpdate) - .build + private val component = ScalaComponent + .builder[CodeSnippets]("CodeSnippets") + .initialState(List.empty[SnippetSummary]) + .backend(new CodeSnippetsBackend(_)) + .renderPS(render) + .componentWillReceiveProps { delta => + val viewChangedToCodeSnippet = delta.currentProps.view != View.CodeSnippets && + delta.nextProps.view == View.CodeSnippets + + val loadProfile: Callback = delta.backend.loadProfile0() + + loadProfile.when_(viewChangedToCodeSnippet) + } + .componentWillMount(_.backend.loadProfile0()) + .configure(Reusability.shouldComponentUpdate) + .build + } diff --git a/client/src/main/scala/org/scastie/client/components/Console.scala b/client/src/main/scala/org/scastie/client/components/Console.scala index 126f0e026..204a5c31f 100644 --- a/client/src/main/scala/org/scastie/client/components/Console.scala +++ b/client/src/main/scala/org/scastie/client/components/Console.scala @@ -1,32 +1,31 @@ package org.scastie.client.components +import japgolly.scalajs.react._ +import org.scalajs.dom.raw.HTMLDivElement import org.scastie.api._ +import org.scastie.client.i18n.I18n import org.scastie.client.ConsoleState import org.scastie.client.HTMLFormatter import org.scastie.client.View -import japgolly.scalajs.react._ -import org.scalajs.dom.raw.HTMLDivElement - import vdom.all._ -import org.scastie.client.i18n.I18n - -final case class Console(isOpen: Boolean, - isRunning: Boolean, - isEmbedded: Boolean, - consoleOutputs: Vector[ConsoleOutput], - run: Reusable[Callback], - setView: View ~=> Callback, - close: Reusable[Callback], - open: Reusable[Callback], - language: String) { +final case class Console( + isOpen: Boolean, + isRunning: Boolean, + isEmbedded: Boolean, + consoleOutputs: Vector[ConsoleOutput], + run: Reusable[Callback], + setView: View ~=> Callback, + close: Reusable[Callback], + open: Reusable[Callback], + language: String +) { @inline def render: VdomElement = Console.component(this) } object Console { - implicit val reusability: Reusability[Console] = - Reusability.derive[Console] + implicit val reusability: Reusability[Console] = Reusability.derive[Console] private val consoleElement = Ref[HTMLDivElement] @@ -36,13 +35,12 @@ object Console { else (display.none, display.flex) val consoleCss = - if (props.isOpen) - TagMod(cls := "console-open") + if (props.isOpen) TagMod(cls := "console-open") else EmptyVdom val (users, systems) = props.consoleOutputs.partition { case u: UserOutput => true - case _ => false + case _ => false } val toShow = @@ -62,7 +60,7 @@ object Console { isStatusOk = true, save = props.run, setView = props.setView, - embedded = true, + embedded = true ).render.when(props.isEmbedded), div(cls := "console-label")( i(cls := "fa fa-terminal"), @@ -81,7 +79,7 @@ object Console { isStatusOk = true, save = props.run, setView = props.setView, - embedded = true, + embedded = true ).render.when(props.isEmbedded), displaySwitcher, div(cls := "console-label")( @@ -93,17 +91,16 @@ object Console { ) } - private val component = - ScalaComponent - .builder[Console]("Console") - .initialState(ConsoleState.default) - .render_P(render) - .componentDidUpdate( - scope => - Callback { - consoleElement.unsafeGet().scrollTop = consoleElement.unsafeGet().scrollHeight.toDouble - }.when_(scope.prevProps.isRunning) - ) - .configure(Reusability.shouldComponentUpdate) - .build + private val component = ScalaComponent + .builder[Console]("Console") + .initialState(ConsoleState.default) + .render_P(render) + .componentDidUpdate(scope => + Callback { + consoleElement.unsafeGet().scrollTop = consoleElement.unsafeGet().scrollHeight.toDouble + }.when_(scope.prevProps.isRunning) + ) + .configure(Reusability.shouldComponentUpdate) + .build + } diff --git a/client/src/main/scala/org/scastie/client/components/CopyModal.scala b/client/src/main/scala/org/scastie/client/components/CopyModal.scala index 97d3b1f95..3f0820c1c 100644 --- a/client/src/main/scala/org/scastie/client/components/CopyModal.scala +++ b/client/src/main/scala/org/scastie/client/components/CopyModal.scala @@ -10,13 +10,13 @@ import org.scalajs.dom.window import vdom.all._ final case class CopyModal( - isDarkTheme: Boolean, - title: String, - subtitle: String, - content: String, - modalId: String, - isClosed: Boolean, - close: Reusable[Callback] + isDarkTheme: Boolean, + title: String, + subtitle: String, + content: String, + modalId: String, + isClosed: Boolean, + close: Reusable[Callback] ) { @inline def render: VdomElement = new CopyModal.ShareModalComponent().build(this) } @@ -30,7 +30,7 @@ object CopyModal { private def render(props: CopyModal): VdomElement = { def copyLink: Callback = divRef.get.map { divRef => - val range = dom.document.createRange() + val range = dom.document.createRange() val selection = dom.window.getSelection() divRef.foreach(range.selectNodeContents) selection.addRange(range) @@ -55,7 +55,7 @@ object CopyModal { props.content ), div(onClick --> copyLink, title := "Copy to Clipboard", cls := "snippet-clip clipboard-copy")( - i(cls := "fa fa-clipboard") + i(cls := "fa fa-clipboard") ) ) ) diff --git a/client/src/main/scala/org/scastie/client/components/DesktopButton.scala b/client/src/main/scala/org/scastie/client/components/DesktopButton.scala index 6273d61fc..c118eb89a 100644 --- a/client/src/main/scala/org/scastie/client/components/DesktopButton.scala +++ b/client/src/main/scala/org/scastie/client/components/DesktopButton.scala @@ -3,7 +3,6 @@ package client package components import japgolly.scalajs.react._ - import vdom.all._ final case class DesktopButton(forceDesktop: Reusable[Callback]) { @@ -11,8 +10,7 @@ final case class DesktopButton(forceDesktop: Reusable[Callback]) { } object DesktopButton { - implicit val reusability: Reusability[DesktopButton] = - Reusability.derive[DesktopButton] + implicit val reusability: Reusability[DesktopButton] = Reusability.derive[DesktopButton] private def render(props: DesktopButton): VdomElement = { li(title := "Go to desktop", cls := "btn", onClick --> props.forceDesktop)( @@ -21,10 +19,10 @@ object DesktopButton { ) } - private val component = - ScalaComponent - .builder[DesktopButton]("DesktopButton") - .render_P(render) - .configure(Reusability.shouldComponentUpdate) - .build + private val component = ScalaComponent + .builder[DesktopButton]("DesktopButton") + .render_P(render) + .configure(Reusability.shouldComponentUpdate) + .build + } diff --git a/client/src/main/scala/org/scastie/client/components/DownloadButton.scala b/client/src/main/scala/org/scastie/client/components/DownloadButton.scala index 84a4fb87f..28fac54e3 100644 --- a/client/src/main/scala/org/scastie/client/components/DownloadButton.scala +++ b/client/src/main/scala/org/scastie/client/components/DownloadButton.scala @@ -1,37 +1,40 @@ package org.scastie.client package components -import org.scastie.api.SnippetId import japgolly.scalajs.react._ - -import vdom.all._ - +import org.scastie.api.SnippetId import org.scastie.client.i18n.I18n +import vdom.all._ final case class DownloadButton(snippetId: SnippetId, language: String) { @inline def render: VdomElement = DownloadButton.component(this) } object DownloadButton { - implicit val reusability: Reusability[DownloadButton] = - Reusability.derive[DownloadButton] + implicit val reusability: Reusability[DownloadButton] = Reusability.derive[DownloadButton] def render(props: DownloadButton): VdomElement = { val url = props.snippetId.url val fullUrl = s"/api/download/$url" li( - a(href := fullUrl, download := url.replaceAll("/", "-") + ".zip", title := s"${I18n.t("editor.download")}", role := "button", cls := "btn")( + a( + href := fullUrl, + download := url.replaceAll("/", "-") + ".zip", + title := s"${I18n.t("editor.download")}", + role := "button", + cls := "btn" + )( i(cls := "fa fa-download"), span(I18n.t("editor.download")) ) ) } - private val component = - ScalaComponent - .builder[DownloadButton]("DownloadButton") - .render_P(render) - .configure(Reusability.shouldComponentUpdate) - .build + private val component = ScalaComponent + .builder[DownloadButton]("DownloadButton") + .render_P(render) + .configure(Reusability.shouldComponentUpdate) + .build + } diff --git a/client/src/main/scala/org/scastie/client/components/EditorTopBar.scala b/client/src/main/scala/org/scastie/client/components/EditorTopBar.scala index 6ba342102..6c78e17da 100644 --- a/client/src/main/scala/org/scastie/client/components/EditorTopBar.scala +++ b/client/src/main/scala/org/scastie/client/components/EditorTopBar.scala @@ -2,44 +2,46 @@ package org.scastie package client package components -import org.scastie.api.{SnippetId, User, ScalaTarget} - -import org.scastie.client.i18n.I18n - -import japgolly.scalajs.react._, vdom.all._, extra.router._, extra._ +import extra._ +import extra.router._ +import japgolly.scalajs.react._ +import org.scastie.api.{ScalaTarget, SnippetId, User} import org.scastie.api.ScalaTargetType - -final case class EditorTopBar(clear: Reusable[Callback], - closeNewSnippetModal: Reusable[Callback], - closeEmbeddedModal: Reusable[Callback], - openEmbeddedModal: Reusable[Callback], - formatCode: Reusable[Callback], - newSnippet: Reusable[Callback], - openNewSnippetModal: Reusable[Callback], - save: Reusable[Callback], - toggleWorksheetMode: Reusable[Callback], - router: Option[RouterCtl[Page]], - inputsHasChanged: Boolean, - isDarkTheme: Boolean, - isNewSnippetModalClosed: Boolean, - isEmbeddedModalClosed: Boolean, - isRunning: Boolean, - isStatusOk: Boolean, - snippetId: Option[SnippetId], - user: Option[User], - view: StateSnapshot[View], - isWorksheetMode: Boolean, - metalsStatus: MetalsStatus, - toggleMetalsStatus: Reusable[Callback], - scalaTarget: ScalaTarget, - language: String) { +import org.scastie.client.i18n.I18n +import vdom.all._ + +final case class EditorTopBar( + clear: Reusable[Callback], + closeNewSnippetModal: Reusable[Callback], + closeEmbeddedModal: Reusable[Callback], + openEmbeddedModal: Reusable[Callback], + formatCode: Reusable[Callback], + newSnippet: Reusable[Callback], + openNewSnippetModal: Reusable[Callback], + save: Reusable[Callback], + toggleWorksheetMode: Reusable[Callback], + router: Option[RouterCtl[Page]], + inputsHasChanged: Boolean, + isDarkTheme: Boolean, + isNewSnippetModalClosed: Boolean, + isEmbeddedModalClosed: Boolean, + isRunning: Boolean, + isStatusOk: Boolean, + snippetId: Option[SnippetId], + user: Option[User], + view: StateSnapshot[View], + isWorksheetMode: Boolean, + metalsStatus: MetalsStatus, + toggleMetalsStatus: Reusable[Callback], + scalaTarget: ScalaTarget, + language: String +) { @inline def render: VdomElement = EditorTopBar.component(this) } object EditorTopBar { - implicit val reusability: Reusability[EditorTopBar] = - Reusability.derive[EditorTopBar] + implicit val reusability: Reusability[EditorTopBar] = Reusability.derive[EditorTopBar] private def render(props: EditorTopBar): VdomElement = { def isDisabled = (cls := "disabled").when(props.view.value != View.Editor) @@ -49,7 +51,7 @@ object EditorTopBar { isStatusOk = props.isStatusOk, save = props.save, setView = Reusable.fn(view => props.view.setState(view)), - embedded = false, + embedded = false ).render val newButton = NewButton( @@ -84,43 +86,37 @@ object EditorTopBar { val metalsButton = MetalsStatusIndicator( props.metalsStatus, props.toggleMetalsStatus, - props.view.value, + props.view.value ).render - val downloadButton = - props.snippetId match { - case Some(sid) => - DownloadButton(snippetId = sid, language = props.language).render - case _ => - EmptyVdom - } - - val embeddedModalButton = - (props.snippetId, props.router) match { - case (Some(sid), Some(router)) => - val url = router.urlFor(Page.fromSnippetId(sid)).value - - val content = - s"""""".stripMargin - - val embeddedModal = - CopyModal( - isDarkTheme = props.isDarkTheme, - title = I18n.t("editor.embed_title"), - subtitle = I18n.t("editor.embed_subtitle"), - modalId = "embed-modal", - content = content, - isClosed = props.isEmbeddedModalClosed, - close = props.closeEmbeddedModal - ).render - - li(title := I18n.t("editor.embed"), role := "button", cls := "btn", onClick --> props.openEmbeddedModal)( - i(cls := "fa fa-code"), - span(I18n.t("editor.embed")), - embeddedModal - ) - case _ => EmptyVdom - } + val downloadButton = props.snippetId match { + case Some(sid) => DownloadButton(snippetId = sid, language = props.language).render + case _ => EmptyVdom + } + + val embeddedModalButton = (props.snippetId, props.router) match { + case (Some(sid), Some(router)) => + val url = router.urlFor(Page.fromSnippetId(sid)).value + + val content = s"""""".stripMargin + + val embeddedModal = CopyModal( + isDarkTheme = props.isDarkTheme, + title = I18n.t("editor.embed_title"), + subtitle = I18n.t("editor.embed_subtitle"), + modalId = "embed-modal", + content = content, + isClosed = props.isEmbeddedModalClosed, + close = props.closeEmbeddedModal + ).render + + li(title := I18n.t("editor.embed"), role := "button", cls := "btn", onClick --> props.openEmbeddedModal)( + i(cls := "fa fa-code"), + span(I18n.t("editor.embed")), + embeddedModal + ) + case _ => EmptyVdom + } nav(cls := "editor-topbar", isDisabled)( ul(cls := "editor-buttons")( @@ -136,10 +132,10 @@ object EditorTopBar { ) } - private val component = - ScalaComponent - .builder[EditorTopBar]("EditorTopBar") - .render_P(render) - .configure(Reusability.shouldComponentUpdate) - .build + private val component = ScalaComponent + .builder[EditorTopBar]("EditorTopBar") + .render_P(render) + .configure(Reusability.shouldComponentUpdate) + .build + } diff --git a/client/src/main/scala/org/scastie/client/components/EmbeddedOverlay.scala b/client/src/main/scala/org/scastie/client/components/EmbeddedOverlay.scala index 2a95a9b31..0f872b3ee 100644 --- a/client/src/main/scala/org/scastie/client/components/EmbeddedOverlay.scala +++ b/client/src/main/scala/org/scastie/client/components/EmbeddedOverlay.scala @@ -1,15 +1,16 @@ package org.scastie.client.components -import org.scastie.api._ import japgolly.scalajs.react._ import org.scalajs.dom +import org.scastie.api._ import vdom.all._ final case class EmbeddedOverlay( - inputsHasChanged: Boolean, - embeddedSnippetId: Option[SnippetId], - serverUrl: Option[String], - save: Reusable[CallbackTo[Option[SnippetId]]]) { + inputsHasChanged: Boolean, + embeddedSnippetId: Option[SnippetId], + serverUrl: Option[String], + save: Reusable[CallbackTo[Option[SnippetId]]] +) { @inline def render: VdomElement = EmbeddedOverlay.component(this) } @@ -27,7 +28,7 @@ object EmbeddedOverlay { props.embeddedSnippetId match { case Some(snippetId) if !props.inputsHasChanged => open(snippetId) - case _ => props.save.asCBO.flatMap(open) + case _ => props.save.asCBO.flatMap(open) } } @@ -40,7 +41,7 @@ object EmbeddedOverlay { } private val component = ScalaFnComponent - .withHooks[EmbeddedOverlay] - .renderWithReuse(render) -} + .withHooks[EmbeddedOverlay] + .renderWithReuse(render) +} diff --git a/client/src/main/scala/org/scastie/client/components/FormatButton.scala b/client/src/main/scala/org/scastie/client/components/FormatButton.scala index 84fd2bc75..d1415f268 100644 --- a/client/src/main/scala/org/scastie/client/components/FormatButton.scala +++ b/client/src/main/scala/org/scastie/client/components/FormatButton.scala @@ -1,12 +1,16 @@ package org.scastie.client.components -import org.scastie.client.components.editor.EditorKeymaps import japgolly.scalajs.react._ import japgolly.scalajs.react.vdom.all._ - +import org.scastie.client.components.editor.EditorKeymaps import org.scastie.client.i18n.I18n -final case class FormatButton(inputsHasChanged: Boolean, isStatusOk: Boolean, formatCode: Reusable[Callback], language: String) { +final case class FormatButton( + inputsHasChanged: Boolean, + isStatusOk: Boolean, + formatCode: Reusable[Callback], + language: String +) { @inline def render: VdomElement = FormatButton.component(this) } @@ -18,17 +22,17 @@ object FormatButton { title := s"${I18n.t("editor.format_tooltip")} (${EditorKeymaps.format.getName})", role := "button", cls := "btn", - onClick --> props.formatCode, + onClick --> props.formatCode )( i(cls := "fa fa-align-left"), span(I18n.t("editor.format")) ) } - private val component = - ScalaComponent - .builder[FormatButton]("FormatButton") - .render_P(render) - .configure(Reusability.shouldComponentUpdate) - .build + private val component = ScalaComponent + .builder[FormatButton]("FormatButton") + .render_P(render) + .configure(Reusability.shouldComponentUpdate) + .build + } diff --git a/client/src/main/scala/org/scastie/client/components/HelpModal.scala b/client/src/main/scala/org/scastie/client/components/HelpModal.scala index a13ef418d..96598b9da 100644 --- a/client/src/main/scala/org/scastie/client/components/HelpModal.scala +++ b/client/src/main/scala/org/scastie/client/components/HelpModal.scala @@ -1,42 +1,37 @@ package org.scastie.client.components import japgolly.scalajs.react._ -import vdom.all._ +import japgolly.scalajs.react.hooks.HookCtx.I1 import org.scastie.client.components.editor.EditorKeymaps - import org.scastie.client.i18n.I18n -import japgolly.scalajs.react.hooks.HookCtx.I1 +import vdom.all._ final case class HelpModal(isDarkTheme: Boolean, isClosed: Boolean, close: Reusable[Callback]) { @inline def render: VdomElement = HelpModal.component(this) } object HelpModal { - implicit val reusability: Reusability[HelpModal] = - Reusability.derive[HelpModal] + implicit val reusability: Reusability[HelpModal] = Reusability.derive[HelpModal] private def render(props: HelpModal): VdomElement = { - def generateATag(url: String, text: String) = - a(href := url, target := "_blank", rel := "nofollow", text) + def generateATag(url: String, text: String) = a(href := url, target := "_blank", rel := "nofollow", text) def renderWithElement(template: String, elementBuilder: String => VdomElement): VdomElement = { val elementRegex = """\{([^}]+)\}""".r - + elementRegex.findFirstMatchIn(template) match { case Some(m) => val before = template.substring(0, m.start) val elementContent = m.group(1) val element = elementBuilder(elementContent) val after = template.substring(m.end) - + p(before, element, after) - case None => - p(template) + case None => p(template) } } - val originalScastie = - generateATag("https://github.com/OlegYch/scastie_old", "GitHub") + val originalScastie = generateATag("https://github.com/OlegYch/scastie_old", "GitHub") Modal( title = I18n.t("help.title"), @@ -46,11 +41,15 @@ object HelpModal { modalCss = TagMod(), modalId = "long-help", content = div(cls := "markdown-body")( - p( I18n.t("help.description")), + p(I18n.t("help.description")), p( renderWithElement( I18n.t("help.sublime_support"), - content => generateATag("https://sublime-text-unofficial-documentation.readthedocs.org/en/latest/reference/keyboard_shortcuts_osx.html", content) + content => + generateATag( + "https://sublime-text-unofficial-documentation.readthedocs.org/en/latest/reference/keyboard_shortcuts_osx.html", + content + ) ) ), h2(I18n.t("help.editor_modes")), @@ -75,7 +74,11 @@ object HelpModal { p( renderWithElement( I18n.t("help.format_description"), - content => generateATag("https://scalameta.org/scalafmt/docs/configuration.html#disabling-or-customizing-formatting", content) + content => + generateATag( + "https://scalameta.org/scalafmt/docs/configuration.html#disabling-or-customizing-formatting", + content + ) ) ), h2(I18n.t("editor.worksheet")), @@ -147,8 +150,8 @@ object HelpModal { ).render } - private val component = - ScalaFnComponent - .withHooks[HelpModal] - .renderWithReuse(render) + private val component = ScalaFnComponent + .withHooks[HelpModal] + .renderWithReuse(render) + } diff --git a/client/src/main/scala/org/scastie/client/components/LoginModal.scala b/client/src/main/scala/org/scastie/client/components/LoginModal.scala index b4850a1ec..9f012709d 100644 --- a/client/src/main/scala/org/scastie/client/components/LoginModal.scala +++ b/client/src/main/scala/org/scastie/client/components/LoginModal.scala @@ -2,19 +2,16 @@ package org.scastie.client package components import japgolly.scalajs.react._ +import japgolly.scalajs.react.hooks.HookCtx.I1 import org.scalajs.dom - -import vdom.all._ - import org.scastie.client.i18n.I18n -import japgolly.scalajs.react.hooks.HookCtx.I1 - +import vdom.all._ final case class LoginModal( - isDarkTheme: Boolean, - isClosed: Boolean, - close: Reusable[Callback], - openPrivacyPolicyModal: Reusable[Callback] + isDarkTheme: Boolean, + isClosed: Boolean, + close: Reusable[Callback], + openPrivacyPolicyModal: Reusable[Callback] ) { @inline def render: VdomElement = LoginModal.component(this) } @@ -23,26 +20,23 @@ object LoginModal { implicit val reusability: Reusability[LoginModal] = Reusability.derive[LoginModal] - - def login: Callback = - Callback(dom.window.location.pathname = "/login") + def login: Callback = Callback(dom.window.location.pathname = "/login") private def renderWithLink(template: String, linkAction: Callback): VdomElement = { val linkRegex = """\{([^}]+)\}""".r - + linkRegex.findFirstMatchIn(template) match { case Some(m) => val before = template.substring(0, m.start) val linkText = m.group(1) val after = template.substring(m.end) - + p( before, a(href := "#", onClick ==> (e => e.preventDefaultCB >> e.stopPropagationCB >> linkAction))(linkText), after ) - case None => - p(template) + case None => p(template) } } @@ -59,7 +53,7 @@ object LoginModal { content = TagMod( button(onClick --> (login >> props.close), cls := "github-login")( i(cls := "fa fa-github"), - I18n.t("sidebar.login_github"), + I18n.t("sidebar.login_github") ), renderWithLink( I18n.t("sidebar.login_agreement"), @@ -69,10 +63,8 @@ object LoginModal { ).render } - private val component = - ScalaFnComponent - .withHooks[LoginModal] - .renderWithReuse(render) - + private val component = ScalaFnComponent + .withHooks[LoginModal] + .renderWithReuse(render) } diff --git a/client/src/main/scala/org/scastie/client/components/MainPanel.scala b/client/src/main/scala/org/scastie/client/components/MainPanel.scala index 0966630ad..6155e1b55 100644 --- a/client/src/main/scala/org/scastie/client/components/MainPanel.scala +++ b/client/src/main/scala/org/scastie/client/components/MainPanel.scala @@ -1,11 +1,11 @@ package org.scastie.client.components +import japgolly.scalajs.react._ +import japgolly.scalajs.react.vdom.all._ +import org.scastie.client.components.editor.CodeEditor import org.scastie.client.ScastieBackend import org.scastie.client.ScastieState import org.scastie.client.View -import org.scastie.client.components.editor.CodeEditor -import japgolly.scalajs.react._ -import japgolly.scalajs.react.vdom.all._ final case class MainPanel(state: ScastieState, backend: ScastieBackend, props: Scastie) { @@ -13,8 +13,7 @@ final case class MainPanel(state: ScastieState, backend: ScastieBackend, props: } object MainPanel { - implicit val reusability: Reusability[MainPanel] = - Reusability.derive[MainPanel] + implicit val reusability: Reusability[MainPanel] = Reusability.derive[MainPanel] def render(in: MainPanel): VdomElement = { import in._ @@ -26,167 +25,155 @@ object MainPanel { val isStatusOk = state.status.isSbtOk - val embeddedMenu = - EmbeddedOverlay( - inputsHasChanged = state.inputsHasChanged, - embeddedSnippetId = props.embeddedSnippetId, - serverUrl = props.serverUrl, - save = backend.saveBlocking, - ).render.when(props.isEmbedded) - - val consoleCssForEditor = - (cls := "console-open").when(state.consoleState.consoleIsOpen) - - val codeSnippets = - (props.router, state.user) match { - case (Some(router), Some(user)) if state.view == View.CodeSnippets => - div(cls := "snippets-container inner-container")( - CodeSnippets( - isDarkTheme = state.isDarkTheme, - view = state.view, - user = user, - router = router, - isShareModalClosed = state.modalState.isShareModalClosed, - closeShareModal = backend.closeShareModal, - openShareModal = backend.openShareModal, - loadProfile = backend.loadProfile, - deleteSnippet = backend.deleteSnippet - ).render - ) - case _ => EmptyVdom - } - - val editor = - CodeEditor( - visible = visible(View.Editor), - isDarkTheme = state.isDarkTheme, - isPresentationMode = state.isPresentationMode, - isWorksheetMode = state.inputs.isWorksheetMode, - isEmbedded = props.isEmbedded, - editorMode = state.editorMode, - showLineNumbers = state.showLineNumbers, - value = state.inputs.code, - attachedDoms = state.attachedDoms, - instrumentations = state.outputs.instrumentations, - compilationInfos = state.outputs.compilationInfos, - runtimeError = state.outputs.runtimeError, - saveOrUpdate = backend.saveOrUpdate, - clear = backend.clear, - openNewSnippetModal = backend.openNewSnippetModal, - toggleHelp = backend.toggleHelpModal, - toggleConsole = backend.toggleConsole, - toggleLineNumbers = backend.toggleLineNumbers, - togglePresentationMode = backend.togglePresentationMode, - formatCode = backend.formatCode, - codeChange = backend.codeChange, - target = state.inputs.target, - metalsStatus = state.metalsStatus, - setMetalsStatus = backend.setMetalsStatus, - updateSettings = backend.updateSettings, - dependencies = state.inputs.libraries, - ).render - - val console = - Console( - isOpen = state.consoleState.consoleIsOpen, - isRunning = state.isRunning, - isEmbedded = props.isEmbedded, - consoleOutputs = state.outputs.consoleOutputs, - run = backend.run, - setView = backend.setViewReused, - close = backend.closeConsole, - open = backend.openConsole, - language = state.language - ).render - - val buildSettings = - BuildSettings( - visible = visible(View.BuildSettings), - inputs = state.inputs, - isDarkTheme = state.isDarkTheme, - isBuildDefault = state.isBuildDefault, - isResetModalClosed = state.modalState.isResetModalClosed, - setTarget = backend.setTarget, - closeResetModal = backend.closeResetModal, - resetBuild = backend.resetBuild, - openResetModal = backend.openResetModal, - sbtConfigChange = backend.sbtConfigChange, - removeScalaDependency = backend.removeScalaDependency, - updateDependencyVersion = backend.updateDependencyVersion, - addScalaDependency = backend.addScalaDependency, - scalaCliConversionError = state.scalaCliConversionError, - language = state.language - ).render - - val mobileBar = - MobileBar( - isRunning = state.isRunning, - isStatusOk = isStatusOk, - isDarkTheme = state.isDarkTheme, - save = backend.saveOrUpdate, - setView = backend.setViewReused, - clear = backend.clear, - isNewSnippetModalClosed = state.modalState.isNewSnippetModalClosed, - openNewSnippetModal = backend.openNewSnippetModal, - closeNewSnippetModal = backend.closeNewSnippetModal, - newSnippet = backend.newSnippet, - forceDesktop = backend.forceDesktop, - language = state.language - ).render - - val topBar = - TopBar( - backend.viewSnapshot(state.view), - state.user, - backend.openLoginModal, - backend.setLanguage, - state.language, - state.isDarkTheme - ).render.unless(props.isEmbedded || state.isPresentationMode) - - val editorTopBar = - EditorTopBar( - clear = backend.clear, - closeNewSnippetModal = backend.closeNewSnippetModal, - closeEmbeddedModal = backend.closeEmbeddedModal, - openEmbeddedModal = backend.openEmbeddedModal, - formatCode = backend.formatCode, - newSnippet = backend.newSnippet, - openNewSnippetModal = backend.openNewSnippetModal, - save = backend.saveOrUpdate, - toggleWorksheetMode = backend.toggleWorksheetMode, - router = props.router, - inputsHasChanged = state.inputsHasChanged, - isDarkTheme = state.isDarkTheme, - isNewSnippetModalClosed = state.modalState.isNewSnippetModalClosed, - isEmbeddedModalClosed = state.modalState.isEmbeddedClosed, - isRunning = state.isRunning, - isStatusOk = isStatusOk, - snippetId = state.snippetId, - user = state.user, - view = backend.viewSnapshot(state.view), - isWorksheetMode = state.inputs.isWorksheetMode, - metalsStatus = state.metalsStatus, - toggleMetalsStatus = backend.toggleMetalsStatus, - scalaTarget = state.inputs.target, - language = state.language - ).render.unless(props.isEmbedded || state.isPresentationMode) - - val statusView = - props.router match { - case Some(router) => - Status( - state = state.status, + val embeddedMenu = EmbeddedOverlay( + inputsHasChanged = state.inputsHasChanged, + embeddedSnippetId = props.embeddedSnippetId, + serverUrl = props.serverUrl, + save = backend.saveBlocking + ).render.when(props.isEmbedded) + + val consoleCssForEditor = (cls := "console-open").when(state.consoleState.consoleIsOpen) + + val codeSnippets = (props.router, state.user) match { + case (Some(router), Some(user)) if state.view == View.CodeSnippets => + div(cls := "snippets-container inner-container")( + CodeSnippets( + isDarkTheme = state.isDarkTheme, + view = state.view, + user = user, router = router, - isAdmin = state.user.exists(_.isAdmin), - inputs = state.inputs, - language = state.language + isShareModalClosed = state.modalState.isShareModalClosed, + closeShareModal = backend.closeShareModal, + openShareModal = backend.openShareModal, + loadProfile = backend.loadProfile, + deleteSnippet = backend.deleteSnippet ).render - case _ => EmptyVdom - } - - val presentationModeClass = - (cls := "presentation-mode").when(state.isPresentationMode) + ) + case _ => EmptyVdom + } + + val editor = CodeEditor( + visible = visible(View.Editor), + isDarkTheme = state.isDarkTheme, + isPresentationMode = state.isPresentationMode, + isWorksheetMode = state.inputs.isWorksheetMode, + isEmbedded = props.isEmbedded, + editorMode = state.editorMode, + showLineNumbers = state.showLineNumbers, + value = state.inputs.code, + attachedDoms = state.attachedDoms, + instrumentations = state.outputs.instrumentations, + compilationInfos = state.outputs.compilationInfos, + runtimeError = state.outputs.runtimeError, + saveOrUpdate = backend.saveOrUpdate, + clear = backend.clear, + openNewSnippetModal = backend.openNewSnippetModal, + toggleHelp = backend.toggleHelpModal, + toggleConsole = backend.toggleConsole, + toggleLineNumbers = backend.toggleLineNumbers, + togglePresentationMode = backend.togglePresentationMode, + formatCode = backend.formatCode, + codeChange = backend.codeChange, + target = state.inputs.target, + metalsStatus = state.metalsStatus, + setMetalsStatus = backend.setMetalsStatus, + updateSettings = backend.updateSettings, + dependencies = state.inputs.libraries + ).render + + val console = Console( + isOpen = state.consoleState.consoleIsOpen, + isRunning = state.isRunning, + isEmbedded = props.isEmbedded, + consoleOutputs = state.outputs.consoleOutputs, + run = backend.run, + setView = backend.setViewReused, + close = backend.closeConsole, + open = backend.openConsole, + language = state.language + ).render + + val buildSettings = BuildSettings( + visible = visible(View.BuildSettings), + inputs = state.inputs, + isDarkTheme = state.isDarkTheme, + isBuildDefault = state.isBuildDefault, + isResetModalClosed = state.modalState.isResetModalClosed, + setTarget = backend.setTarget, + closeResetModal = backend.closeResetModal, + resetBuild = backend.resetBuild, + openResetModal = backend.openResetModal, + sbtConfigChange = backend.sbtConfigChange, + removeScalaDependency = backend.removeScalaDependency, + updateDependencyVersion = backend.updateDependencyVersion, + addScalaDependency = backend.addScalaDependency, + scalaCliConversionError = state.scalaCliConversionError, + language = state.language + ).render + + val mobileBar = MobileBar( + isRunning = state.isRunning, + isStatusOk = isStatusOk, + isDarkTheme = state.isDarkTheme, + save = backend.saveOrUpdate, + setView = backend.setViewReused, + clear = backend.clear, + isNewSnippetModalClosed = state.modalState.isNewSnippetModalClosed, + openNewSnippetModal = backend.openNewSnippetModal, + closeNewSnippetModal = backend.closeNewSnippetModal, + newSnippet = backend.newSnippet, + forceDesktop = backend.forceDesktop, + language = state.language + ).render + + val topBar = TopBar( + backend.viewSnapshot(state.view), + state.user, + backend.openLoginModal, + backend.setLanguage, + state.language, + state.isDarkTheme + ).render.unless(props.isEmbedded || state.isPresentationMode) + + val editorTopBar = EditorTopBar( + clear = backend.clear, + closeNewSnippetModal = backend.closeNewSnippetModal, + closeEmbeddedModal = backend.closeEmbeddedModal, + openEmbeddedModal = backend.openEmbeddedModal, + formatCode = backend.formatCode, + newSnippet = backend.newSnippet, + openNewSnippetModal = backend.openNewSnippetModal, + save = backend.saveOrUpdate, + toggleWorksheetMode = backend.toggleWorksheetMode, + router = props.router, + inputsHasChanged = state.inputsHasChanged, + isDarkTheme = state.isDarkTheme, + isNewSnippetModalClosed = state.modalState.isNewSnippetModalClosed, + isEmbeddedModalClosed = state.modalState.isEmbeddedClosed, + isRunning = state.isRunning, + isStatusOk = isStatusOk, + snippetId = state.snippetId, + user = state.user, + view = backend.viewSnapshot(state.view), + isWorksheetMode = state.inputs.isWorksheetMode, + metalsStatus = state.metalsStatus, + toggleMetalsStatus = backend.toggleMetalsStatus, + scalaTarget = state.inputs.target, + language = state.language + ).render.unless(props.isEmbedded || state.isPresentationMode) + + val statusView = props.router match { + case Some(router) => Status( + state = state.status, + router = router, + isAdmin = state.user.exists(_.isAdmin), + inputs = state.inputs, + language = state.language + ).render + case _ => EmptyVdom + } + + val presentationModeClass = (cls := "presentation-mode").when(state.isPresentationMode) div( cls := "main-panel", @@ -215,10 +202,10 @@ object MainPanel { } - private val component = - ScalaComponent - .builder[MainPanel]("MainPanel") - .render_P(render) - .configure(Reusability.shouldComponentUpdate) - .build + private val component = ScalaComponent + .builder[MainPanel]("MainPanel") + .render_P(render) + .configure(Reusability.shouldComponentUpdate) + .build + } diff --git a/client/src/main/scala/org/scastie/client/components/MetalsStatusIndicator.scala b/client/src/main/scala/org/scastie/client/components/MetalsStatusIndicator.scala index b0752823a..0828b6d12 100644 --- a/client/src/main/scala/org/scastie/client/components/MetalsStatusIndicator.scala +++ b/client/src/main/scala/org/scastie/client/components/MetalsStatusIndicator.scala @@ -2,19 +2,17 @@ package org.scastie package client package components -import japgolly.scalajs.react._ - import scala.scalajs.js.annotation.JSImport -import vdom.all._ -import scalajs.js - +import japgolly.scalajs.react._ import org.scastie.client.i18n.I18n +import scalajs.js +import vdom.all._ final case class MetalsStatusIndicator( metalsStatus: MetalsStatus, toggleMetalsStatus: Reusable[Callback], - view: View, + view: View ) { @inline def render: VdomElement = MetalsStatusIndicator.component(this) } @@ -23,17 +21,16 @@ final case class MetalsStatusIndicator( @js.native object MetalsLogo extends js.Any - object MetalsStatusIndicator { def metalsLogo: String = MetalsLogo.asInstanceOf[String] def getIndicatorIconClasses(status: MetalsStatus): String = { status match { - case MetalsLoading => "metals-loading fa-spinner fa-spin" - case MetalsDisabled => "metals-disabled fa-circle" - case MetalsReady => "metals-ready fa-circle" - case OutdatedScalaCli => "metals-outdated fa-circle" - case _: NetworkError => "fa-exclamation-circle" + case MetalsLoading => "metals-loading fa-spinner fa-spin" + case MetalsDisabled => "metals-disabled fa-circle" + case MetalsReady => "metals-ready fa-circle" + case OutdatedScalaCli => "metals-outdated fa-circle" + case _: NetworkError => "fa-exclamation-circle" case _: MetalsConfigurationError => "fa-exclamation-triangle" } } @@ -43,7 +40,7 @@ object MetalsStatusIndicator { title := props.metalsStatus.info, role := "button", cls := "btn editor metals-status-indicator", - onClick --> props.toggleMetalsStatus, + onClick --> props.toggleMetalsStatus )( img(src := metalsLogo), span(I18n.t("editor.metals_status")), @@ -51,8 +48,8 @@ object MetalsStatusIndicator { ) } - private val component = - ScalaFnComponent - .withHooks[MetalsStatusIndicator] - .render(props => MetalsStatusIndicator.render(props)) + private val component = ScalaFnComponent + .withHooks[MetalsStatusIndicator] + .render(props => MetalsStatusIndicator.render(props)) + } diff --git a/client/src/main/scala/org/scastie/client/components/MobileBar.scala b/client/src/main/scala/org/scastie/client/components/MobileBar.scala index b5ba3f924..71fd178fb 100644 --- a/client/src/main/scala/org/scastie/client/components/MobileBar.scala +++ b/client/src/main/scala/org/scastie/client/components/MobileBar.scala @@ -1,27 +1,28 @@ package org.scastie.client.components -import org.scastie.client.View import japgolly.scalajs.react._ import japgolly.scalajs.react.vdom.all._ +import org.scastie.client.View -final case class MobileBar(isRunning: Boolean, - isStatusOk: Boolean, - isDarkTheme: Boolean, - save: Reusable[Callback], - setView: View ~=> Callback, - isNewSnippetModalClosed: Boolean, - clear: Reusable[Callback], - openNewSnippetModal: Reusable[Callback], - closeNewSnippetModal: Reusable[Callback], - newSnippet: Reusable[Callback], - forceDesktop: Reusable[Callback], - language: String) { +final case class MobileBar( + isRunning: Boolean, + isStatusOk: Boolean, + isDarkTheme: Boolean, + save: Reusable[Callback], + setView: View ~=> Callback, + isNewSnippetModalClosed: Boolean, + clear: Reusable[Callback], + openNewSnippetModal: Reusable[Callback], + closeNewSnippetModal: Reusable[Callback], + newSnippet: Reusable[Callback], + forceDesktop: Reusable[Callback], + language: String +) { @inline def render: VdomElement = MobileBar.component(this) } object MobileBar { - implicit val reusability: Reusability[MobileBar] = - Reusability.derive[MobileBar] + implicit val reusability: Reusability[MobileBar] = Reusability.derive[MobileBar] private def render(props: MobileBar): VdomElement = { nav(cls := "editor-mobile")( @@ -31,7 +32,7 @@ object MobileBar { isStatusOk = props.isStatusOk, save = props.save, setView = props.setView, - embedded = false, + embedded = false ).render, NewButton( isDarkTheme = props.isDarkTheme, @@ -44,8 +45,8 @@ object MobileBar { ClearButton( clear = props.clear, language = props.language - ).render, - //this doesn't work too well, better use browsers 'request desktop site' + ).render + // this doesn't work too well, better use browsers 'request desktop site' // DesktopButton( // forceDesktop = props.forceDesktop // ).render @@ -53,10 +54,10 @@ object MobileBar { ) } - private val component = - ScalaComponent - .builder[MobileBar]("MobileBar") - .render_P(render) - .configure(Reusability.shouldComponentUpdate) - .build + private val component = ScalaComponent + .builder[MobileBar]("MobileBar") + .render_P(render) + .configure(Reusability.shouldComponentUpdate) + .build + } diff --git a/client/src/main/scala/org/scastie/client/components/Modal.scala b/client/src/main/scala/org/scastie/client/components/Modal.scala index 40dfa5c40..ff4bdd6c4 100644 --- a/client/src/main/scala/org/scastie/client/components/Modal.scala +++ b/client/src/main/scala/org/scastie/client/components/Modal.scala @@ -4,13 +4,13 @@ import japgolly.scalajs.react._ import vdom.all._ final case class Modal( - title: String, - isDarkTheme: Boolean, - isClosed: Boolean, - close: Reusable[Callback], - modalCss: TagMod, - modalId: String, - content: TagMod + title: String, + isDarkTheme: Boolean, + isClosed: Boolean, + close: Reusable[Callback], + modalCss: TagMod, + modalId: String, + content: TagMod ) { @inline def render: VdomElement = Modal.component(this) } @@ -31,7 +31,7 @@ object Modal { div( cls := "modal-close", onClick ==> (e => e.stopPropagationCB >> props.close), - role := "button", + role := "button", title := "close help modal" ) )( diff --git a/client/src/main/scala/org/scastie/client/components/NewButton.scala b/client/src/main/scala/org/scastie/client/components/NewButton.scala index d2debf4ec..08c32cac4 100644 --- a/client/src/main/scala/org/scastie/client/components/NewButton.scala +++ b/client/src/main/scala/org/scastie/client/components/NewButton.scala @@ -1,27 +1,25 @@ package org.scastie.client package components -import org.scastie.client.components.editor.EditorKeymaps import japgolly.scalajs.react._ - +import japgolly.scalajs.react.hooks.HookCtx.I18 +import org.scastie.client.components.editor.EditorKeymaps import org.scastie.client.i18n.I18n - import vdom.all._ -import japgolly.scalajs.react.hooks.HookCtx.I18 final case class NewButton( - isDarkTheme: Boolean, - isNewSnippetModalClosed: Boolean, - openNewSnippetModal: Reusable[Callback], - closeNewSnippetModal: Reusable[Callback], - newSnippet: Reusable[Callback], - language: String) { + isDarkTheme: Boolean, + isNewSnippetModalClosed: Boolean, + openNewSnippetModal: Reusable[Callback], + closeNewSnippetModal: Reusable[Callback], + newSnippet: Reusable[Callback], + language: String +) { @inline def render: VdomElement = NewButton.component(this) } object NewButton { - implicit val reusability: Reusability[NewButton] = - Reusability.derive[NewButton] + implicit val reusability: Reusability[NewButton] = Reusability.derive[NewButton] def render(props: NewButton): VdomElement = { @@ -46,10 +44,10 @@ object NewButton { ) } - private val component = - ScalaComponent - .builder[NewButton]("NewButton") - .render_P(render) - .configure(Reusability.shouldComponentUpdate) - .build + private val component = ScalaComponent + .builder[NewButton]("NewButton") + .render_P(render) + .configure(Reusability.shouldComponentUpdate) + .build + } diff --git a/client/src/main/scala/org/scastie/client/components/PrivacyPolicyModal.scala b/client/src/main/scala/org/scastie/client/components/PrivacyPolicyModal.scala index 44ddedee4..9c7dd0418 100644 --- a/client/src/main/scala/org/scastie/client/components/PrivacyPolicyModal.scala +++ b/client/src/main/scala/org/scastie/client/components/PrivacyPolicyModal.scala @@ -1,17 +1,17 @@ package org.scastie.client.components +import scala.scalajs.js.annotation.JSImport + import japgolly.scalajs.react._ import scalajs.js import vdom.all._ -import scala.scalajs.js.annotation.JSImport final case class PrivacyPolicyModal(isDarkTheme: Boolean, isClosed: Boolean, close: Reusable[Callback]) { @inline def render: VdomElement = PrivacyPolicyModal.component(this) } object PrivacyPolicyModal { - implicit val reusability: Reusability[PrivacyPolicyModal] = - Reusability.derive[PrivacyPolicyModal] + implicit val reusability: Reusability[PrivacyPolicyModal] = Reusability.derive[PrivacyPolicyModal] @js.native @JSImport("@scastieRoot/privacy-policy.md", "html") @@ -31,8 +31,8 @@ object PrivacyPolicyModal { ).render } - private val component = - ScalaFnComponent - .withHooks[PrivacyPolicyModal] - .renderWithReuse(render) + private val component = ScalaFnComponent + .withHooks[PrivacyPolicyModal] + .renderWithReuse(render) + } diff --git a/client/src/main/scala/org/scastie/client/components/PrivacyPolicyPrompt.scala b/client/src/main/scala/org/scastie/client/components/PrivacyPolicyPrompt.scala index d1c30cdad..edc500add 100644 --- a/client/src/main/scala/org/scastie/client/components/PrivacyPolicyPrompt.scala +++ b/client/src/main/scala/org/scastie/client/components/PrivacyPolicyPrompt.scala @@ -1,30 +1,25 @@ package org.scastie.client package components - import japgolly.scalajs.react._ import org.scalajs.dom - import vdom.all._ - // scheduled for removal 2023-04-30 @deprecated("Scheduled for removal", "2023-04-30") final case class PrivacyPolicyPrompt( - isDarkTheme: Boolean, - isClosed: Boolean, - acceptPrivacyPolicy: Reusable[Callback], - refusePrivacyPolicy: Reusable[Callback], - openPrivacyPolicyModal: Reusable[Callback] - ) { + isDarkTheme: Boolean, + isClosed: Boolean, + acceptPrivacyPolicy: Reusable[Callback], + refusePrivacyPolicy: Reusable[Callback], + openPrivacyPolicyModal: Reusable[Callback] +) { @inline def render: VdomElement = PrivacyPolicyPrompt.component(this) } - @deprecated("Scheduled for removal", "2023-04-30") object PrivacyPolicyPrompt { - implicit val reusability: Reusability[PrivacyPolicyPrompt] = - Reusability.derive[PrivacyPolicyPrompt] + implicit val reusability: Reusability[PrivacyPolicyPrompt] = Reusability.derive[PrivacyPolicyPrompt] def reloadWindow = Reusable.always(Callback { dom.window.location.reload() }) @@ -40,11 +35,15 @@ object PrivacyPolicyPrompt { modalId = "privacy-policy-prompt", content = TagMod( div(cls := "modal-intro")( - p("""With the introduction of privacy policy to Scastie, you have to decide + p( + """With the introduction of privacy policy to Scastie, you have to decide | whether you want to keep your existing code snippets, or remove them all from our database. | By keeping the snippets, you acknowledge that you have read and agreed | to the privacy policy terms available """.stripMargin.stripLineEnd, - a(href := "#", onClick ==> (e => e.preventDefaultCB >> e.stopPropagationCB >> props.openPrivacyPolicyModal))( + a( + href := "#", + onClick ==> (e => e.preventDefaultCB >> e.stopPropagationCB >> props.openPrivacyPolicyModal) + )( "here" ), "." @@ -62,15 +61,13 @@ object PrivacyPolicyPrompt { ), p( """If you do not explicitly ask us to keep your snippets before April 30th 2023, we will delete them all.""" - ), + ) ), ul( li(onClick ==> (e => e.stopPropagationCB >> props.acceptPrivacyPolicy), cls := "btn")( "Keep my existing snippets" ), - li(onClick ==> (e => - e.stopPropagationCB >> props.refusePrivacyPolicy - ), cls := "btn")( + li(onClick ==> (e => e.stopPropagationCB >> props.refusePrivacyPolicy), cls := "btn")( "Delete my existing snippets" ) ) @@ -78,8 +75,8 @@ object PrivacyPolicyPrompt { ).render } - private val component = - ScalaFnComponent - .withHooks[PrivacyPolicyPrompt] - .renderWithReuse(render) + private val component = ScalaFnComponent + .withHooks[PrivacyPolicyPrompt] + .renderWithReuse(render) + } diff --git a/client/src/main/scala/org/scastie/client/components/PromptModal.scala b/client/src/main/scala/org/scastie/client/components/PromptModal.scala index 63de3baff..eeeaceab1 100644 --- a/client/src/main/scala/org/scastie/client/components/PromptModal.scala +++ b/client/src/main/scala/org/scastie/client/components/PromptModal.scala @@ -3,28 +3,26 @@ package client package components import japgolly.scalajs.react._ - -import vdom.all._ - import org.scastie.client.i18n.I18n +import vdom.all._ final case class PromptModal( - isDarkTheme: Boolean, - modalText: String, - modalId: String, - isClosed: Boolean, - close: Reusable[Callback], - actionText: String, - actionLabel: String, - action: Reusable[Callback]) { + isDarkTheme: Boolean, + modalText: String, + modalId: String, + isClosed: Boolean, + close: Reusable[Callback], + actionText: String, + actionLabel: String, + action: Reusable[Callback] +) { @inline def render: VdomElement = PromptModal.component(this) } object PromptModal { - implicit val reusability: Reusability[PromptModal] = - Reusability.derive[PromptModal] + implicit val reusability: Reusability[PromptModal] = Reusability.derive[PromptModal] private def render(props: PromptModal): VdomElement = { Modal( @@ -40,10 +38,7 @@ object PromptModal { props.actionText ), ul( - li(onClick ==> ( - e => e.stopPropagationCB >> props.action >> props.close - ), - cls := "btn")( + li(onClick ==> (e => e.stopPropagationCB >> props.action >> props.close), cls := "btn")( props.actionLabel ), li(onClick ==> (e => e.stopPropagationCB >> props.close), cls := "btn")( @@ -54,10 +49,10 @@ object PromptModal { ).render } - private val component = - ScalaComponent - .builder[PromptModal]("PrompModal") - .render_P(render) - .configure(Reusability.shouldComponentUpdate) - .build + private val component = ScalaComponent + .builder[PromptModal]("PrompModal") + .render_P(render) + .configure(Reusability.shouldComponentUpdate) + .build + } diff --git a/client/src/main/scala/org/scastie/client/components/RunButton.scala b/client/src/main/scala/org/scastie/client/components/RunButton.scala index 4447d97ff..f23093139 100644 --- a/client/src/main/scala/org/scastie/client/components/RunButton.scala +++ b/client/src/main/scala/org/scastie/client/components/RunButton.scala @@ -1,31 +1,38 @@ package org.scastie.client package components -import org.scastie.client.components.editor.EditorKeymaps import japgolly.scalajs.react._ - +import org.scastie.client.components.editor.EditorKeymaps import org.scastie.client.i18n.I18n - import vdom.all._ -final case class RunButton(isRunning: Boolean, isStatusOk: Boolean, save: Reusable[Callback], setView: View ~=> Callback, embedded: Boolean) { +final case class RunButton( + isRunning: Boolean, + isStatusOk: Boolean, + save: Reusable[Callback], + setView: View ~=> Callback, + embedded: Boolean +) { @inline def render: VdomElement = RunButton.component(this) } object RunButton { - implicit val reusability: Reusability[RunButton] = - Reusability.derive[RunButton] + implicit val reusability: Reusability[RunButton] = Reusability.derive[RunButton] def render(props: RunButton): VdomElement = { if (!props.isRunning) { val runTitle = - if (props.isStatusOk) - s"${I18n.t("editor.run")} (${EditorKeymaps.saveOrUpdate.getName})" + if (props.isStatusOk) s"${I18n.t("editor.run")} (${EditorKeymaps.saveOrUpdate.getName})" else s"${I18n.t("editor.run")} (${EditorKeymaps.saveOrUpdate.getName}) - ${I18n.t("editor.status_unknown_warning")}" - li(onClick ==> { e => e.stopPropagationCB >> props.save }, role := "button", title := runTitle, cls := "btn run-button")( + li( + onClick ==> { e => e.stopPropagationCB >> props.save }, + role := "button", + title := runTitle, + cls := "btn run-button" + )( i(cls := "fa fa-play"), span(I18n.t("editor.run")) ) @@ -37,10 +44,10 @@ object RunButton { } } - private val component = - ScalaComponent - .builder[RunButton]("RunButton") - .render_P(render) - .configure(Reusability.shouldComponentUpdate) - .build + private val component = ScalaComponent + .builder[RunButton]("RunButton") + .render_P(render) + .configure(Reusability.shouldComponentUpdate) + .build + } diff --git a/client/src/main/scala/org/scastie/client/components/ScaladexSearch.scala b/client/src/main/scala/org/scastie/client/components/ScaladexSearch.scala index dffd39d60..e3c5d9f26 100644 --- a/client/src/main/scala/org/scastie/client/components/ScaladexSearch.scala +++ b/client/src/main/scala/org/scastie/client/components/ScaladexSearch.scala @@ -1,24 +1,22 @@ package org.scastie.client.components -import org.scastie.api._ -import org.scastie.buildinfo.BuildInfo -import japgolly.scalajs.react._ -import japgolly.scalajs.react.component.builder.Lifecycle.RenderScope -import org.scalajs.dom - import scala.concurrent.Future - -import vdom.all._ -import dom.ext.KeyCode -import dom.{HTMLInputElement, HTMLElement} -import scalajs.js.Thenable.Implicits._ -import scalajs.concurrent.JSExecutionContext.Implicits.queue import scala.scalajs.js +import dom.{HTMLElement, HTMLInputElement} +import dom.ext.KeyCode import io.circe._ -import io.circe.syntax._ import io.circe.parser._ +import io.circe.syntax._ +import japgolly.scalajs.react._ +import japgolly.scalajs.react.component.builder.Lifecycle.RenderScope +import org.scalajs.dom +import org.scastie.api._ +import org.scastie.buildinfo.BuildInfo import org.scastie.client.i18n.I18n +import scalajs.concurrent.JSExecutionContext.Implicits.queue +import scalajs.js.Thenable.Implicits._ +import vdom.all._ final case class ScaladexSearch( removeScalaDependency: ScalaDependency ~=> Callback, @@ -34,17 +32,13 @@ final case class ScaladexSearch( object ScaladexSearch { - implicit val propsReusability: Reusability[ScaladexSearch] = - Reusability.derive[ScaladexSearch] + implicit val propsReusability: Reusability[ScaladexSearch] = Reusability.derive[ScaladexSearch] - implicit val selectedReusability: Reusability[Selected] = - Reusability.derive[Selected] + implicit val selectedReusability: Reusability[Selected] = Reusability.derive[Selected] - implicit val stateReusability: Reusability[SearchState] = - Reusability.derive[SearchState] + implicit val stateReusability: Reusability[SearchState] = Reusability.derive[SearchState] - private def toQuery(in: Map[String, String]): String = - in.map { case (k, v) => s"$k=$v" }.mkString("?", "&", "") + private def toQuery(in: Map[String, String]): String = in.map { case (k, v) => s"$k=$v" }.mkString("?", "&", "") def queryAndParse(t: SbtScalaTarget, query: String): Future[List[(Project, ScalaTarget)]] = { val q = toQuery(t.scaladexRequest + ("q" -> query)) @@ -57,76 +51,82 @@ object ScaladexSearch { } private def fetchSelected(project: Project, artifact: String, target: SbtScalaTarget, version: Option[String]) = { - val query = toQuery( - Map( - "organization" -> project.organization, - "repository" -> project.repository - ) ++ target.scaladexRequest - ) + val query = toQuery( + Map( + "organization" -> project.organization, + "repository" -> project.repository + ) ++ target.scaladexRequest + ) - for { - response <- dom.fetch(scaladexApiUrl + "/project" + query) - text <- response.text() - - artifactResponse <- dom.fetch(scaladexApiUrl + s"/v1/projects/${project.organization}/${project.repository}/versions/latest") - artifactText <- artifactResponse.text() - artifactJson = parse(artifactText).getOrElse(Json.Null) - - matchingArtifact: Option[Json] = artifactJson.asArray.getOrElse(Vector.empty).find { artifactObj => - val artifactId = artifactObj.hcursor.get[String]("artifactId").getOrElse("") - val targetSuffix = target.targetType match { - case ScalaTargetType.Scala3 => "_3" - case ScalaTargetType.Scala2 => s"_${target.binaryScalaVersion}" - case ScalaTargetType.JS => s"_sjs1_${target.binaryScalaVersion}" - } - artifactId == artifact || artifactId == s"${artifact}${targetSuffix}" + for { + response <- dom.fetch(scaladexApiUrl + "/project" + query) + text <- response.text() + + artifactResponse <- + dom.fetch(scaladexApiUrl + s"/v1/projects/${project.organization}/${project.repository}/versions/latest") + artifactText <- artifactResponse.text() + artifactJson = parse(artifactText).getOrElse(Json.Null) + + matchingArtifact: Option[Json] = artifactJson.asArray.getOrElse(Vector.empty).find { artifactObj => + val artifactId = artifactObj.hcursor.get[String]("artifactId").getOrElse("") + val targetSuffix = target.targetType match { + case ScalaTargetType.Scala3 => "_3" + case ScalaTargetType.Scala2 => s"_${target.binaryScalaVersion}" + case ScalaTargetType.JS => s"_sjs1_${target.binaryScalaVersion}" } - matchingGroupId = matchingArtifact.flatMap(obj => obj.hcursor.get[String]("groupId").toOption) - matchingVersion = matchingArtifact.flatMap(obj => obj.hcursor.get[String]("version").toOption).orElse(version) - } yield { - decode[ReleaseOptions](text).toOption.map{ options => - { - Selected( - project = project, - release = ScalaDependency( - groupId = matchingGroupId.getOrElse(options.groupId), - artifact = artifact, - target = target, - version = matchingVersion.getOrElse(options.version), - ), - options = options, - ) - } + artifactId == artifact || artifactId == s"${artifact}${targetSuffix}" + } + matchingGroupId = matchingArtifact.flatMap(obj => obj.hcursor.get[String]("groupId").toOption) + matchingVersion = matchingArtifact.flatMap(obj => obj.hcursor.get[String]("version").toOption).orElse(version) + } yield { + decode[ReleaseOptions](text).toOption.map { options => + { + Selected( + project = project, + release = ScalaDependency( + groupId = matchingGroupId.getOrElse(options.groupId), + artifact = artifact, + target = target, + version = matchingVersion.getOrElse(options.version) + ), + options = options + ) } } } + } - def addArtifact(projectAndArtifact: (Project, String, Option[String]), target: ScalaTarget, state: hooks.Hooks.UseStateF[CallbackTo, SearchState], props: ScaladexSearch): Callback = { + def addArtifact( + projectAndArtifact: (Project, String, Option[String]), + target: ScalaTarget, + state: hooks.Hooks.UseStateF[CallbackTo, SearchState], + props: ScaladexSearch + ): Callback = { val (project, artifact, version) = projectAndArtifact if (state.value.selecteds.exists(_.matches(project, artifact))) Callback(()) - else - Callback.future { - target match { - case sbtScalaTarget: SbtScalaTarget => - fetchSelected(project, artifact, sbtScalaTarget, version).map { - case Some(selected) if !state.value.selecteds.exists(_.release.matches(selected.release)) => - state.modState(_.addSelected(selected)) >> props.addScalaDependency(selected.release -> selected.project) - case _ => Callback(()) - } - case _ => Future.successful(Callback(())) - } + else Callback.future { + target match { + case sbtScalaTarget: SbtScalaTarget => fetchSelected(project, artifact, sbtScalaTarget, version).map { + case Some(selected) if !state.value.selecteds.exists(_.release.matches(selected.release)) => + state.modState(_.addSelected(selected)) >> props.addScalaDependency(selected.release -> selected.project) + case _ => Callback(()) + } + case _ => Future.successful(Callback(())) } + } } private[ScaladexSearch] object SearchState { + def default: SearchState = { SearchState( query = "", selectedIndex = 0, projects = List.empty, - selecteds = List.empty, + selecteds = List.empty ) } + } private[ScaladexSearch] case class Selected( @@ -141,7 +141,7 @@ object ScaladexSearch { query: String, selectedIndex: Int, projects: List[(Project, ScalaTarget)], - selecteds: List[Selected], + selecteds: List[Selected] ) { private val selectedProjectsArtifacts = selecteds @@ -155,34 +155,31 @@ object ScaladexSearch { val orgLower = project.organization.toLowerCase (queryLower, artifactLower, projectLower, orgLower) match { - case (q, a, _, _) if a == q => 1000 + case (q, a, _, _) if a == q => 1000 case (q, a, _, _) if a.startsWith(q) => 800 - case (q, a, _, _) if a.contains(q) => 600 - case (q, _, p, _) if p.contains(q) => 400 - case (q, _, _, o) if o.contains(q) => 200 - case _ => 0 + case (q, a, _, _) if a.contains(q) => 600 + case (q, _, p, _) if p.contains(q) => 400 + case (q, _, _, o) if o.contains(q) => 200 + case _ => 0 } } val search: List[(Project, String, Option[String], ScalaTarget)] = { val results = projects - .flatMap { - case (project, target) => project.artifacts.map(artifact => (project, artifact, None, target)) + .flatMap { case (project, target) => + project.artifacts.map(artifact => (project, artifact, None, target)) } .filter { projectAndArtifact => !selectedProjectsArtifacts.contains(projectAndArtifact) } if (query.nonEmpty) { - results.sortBy({ case (project, artifact, _, _) => - -matchScore(query, artifact, project) - })(Ordering[Int]) + results.sortBy({ case (project, artifact, _, _) => -matchScore(query, artifact, project) })(Ordering[Int]) } else { - results.sortBy { case (project, artifact, _, _) => - (project.organization, project.repository, artifact) - } + results.sortBy { case (project, artifact, _, _) => (project.organization, project.repository, artifact) } } } + def removeSelected(selected: Selected): SearchState = { copy(selecteds = selecteds.filterNot(_.release.matches(selected.release))) } @@ -194,7 +191,10 @@ object ScaladexSearch { } def updateVersion(selected: Selected, version: String): SearchState = { - val updated = selected.copy(release = selected.release.copy(version = version), options = selected.options.copy(version = version)) + val updated = selected.copy( + release = selected.release.copy(version = version), + options = selected.options.copy(version = version) + ) copy( selecteds = selecteds.filterNot(_.release.matches(updated.release)) :+ updated ) @@ -207,31 +207,29 @@ object ScaladexSearch { def clearProjects: SearchState = { copy(projects = List()) } + } // private val scaladexBaseUrl = "http://localhost:8080" private val scaladexBaseUrl = "https://index.scala-lang.org" private val scaladexApiUrl = scaladexBaseUrl + "/api" - private implicit val projectOrdering: Ordering[Project] = - Ordering.by { project: Project => - (project.organization, project.repository) - } + private implicit val projectOrdering: Ordering[Project] = Ordering.by { project: Project => + (project.organization, project.repository) + } - private implicit val scalaDependenciesOrdering: Ordering[ScalaDependency] = - Ordering.by { scalaDependency: ScalaDependency => + private implicit val scalaDependenciesOrdering: Ordering[ScalaDependency] = Ordering.by { + scalaDependency: ScalaDependency => scalaDependency.artifact - } + } - private implicit val selectedOrdering: Ordering[Selected] = - Ordering.by { selected: Selected => - (selected.project, selected.release) - } + private implicit val selectedOrdering: Ordering[Selected] = Ordering.by { selected: Selected => + (selected.project, selected.release) + } private val projectListRef = Ref[HTMLElement] private val searchInputRef = Ref[HTMLInputElement] - private def render(props: ScaladexSearch, state: hooks.Hooks.UseStateF[CallbackTo, SearchState]): VdomElement = { def keyDown(e: ReactKeyboardEventFromInput): Callback = { @@ -255,13 +253,11 @@ object ScaladexSearch { ) } - def selectProject = - state.modState( - s => - s.copy( - selectedIndex = clamp(s.search.size, s.selectedIndex + diff) - ) + def selectProject = state.modState(s => + s.copy( + selectedIndex = clamp(s.search.size, s.selectedIndex + diff) ) + ) def scrollToSelectedProject = Callback { scrollToSelected(state.value.selectedIndex, state.value.search.size) @@ -273,13 +269,13 @@ object ScaladexSearch { } else if (e.keyCode == KeyCode.Enter) { - def addArtifactIfInRange = - for { - _ <- if (0 <= state.value.selectedIndex && state.value.selectedIndex < state.value.search.size) { + def addArtifactIfInRange = for { + _ <- + if (0 <= state.value.selectedIndex && state.value.selectedIndex < state.value.search.size) { val (p, a, v, t) = state.value.search(state.value.selectedIndex) addArtifact((p, a, v), t, state, props) } else Callback.empty - } yield () + } yield () addArtifactIfInRange >> Callback(searchInputRef.unsafeGet().focus()) } else { @@ -302,11 +298,9 @@ object ScaladexSearch { updateDependencyVersionBackend >> updateDependencyVersionLocal } - def selectIndex(index: Int): Callback = - state.modState(s => s.copy(selectedIndex = index)) + def selectIndex(index: Int): Callback = state.modState(s => s.copy(selectedIndex = index)) - def resetQuery: Callback = - state.modState(s => s.copy(query = "", projects = Nil)) + def resetQuery: Callback = state.modState(s => s.copy(query = "", projects = Nil)) def setQuery(e: ReactEventFromInput): Callback = { state.modState(_.copy(query = e.target.value)) >> fetchProjects() @@ -319,8 +313,7 @@ object ScaladexSearch { val projsForThisTarget = queryAndParse(target, searchState.query) val projects: Future[List[(Project, ScalaTarget)]] = target match { // If scala3 but no scala 3 versions available, offer 2.13 artifacts - case Scala3(_) => - projsForThisTarget.flatMap { ls => + case Scala3(_) => projsForThisTarget.flatMap { ls => queryAndParse(Scala2(BuildInfo.latest213), searchState.query) .map(arts213 => ls ::: arts213) } @@ -336,31 +329,29 @@ object ScaladexSearch { fetch(props.scalaTarget, state.value) } - def selectedIndex(index: Int, selected: Int) = - (cls := "selected").when(index == selected) + def selectedIndex(index: Int, selected: Int) = (cls := "selected").when(index == selected) - def renderProject(project: Project, - artifact: String, - scalaTarget: ScalaTarget, - selected: TagMod, - handlers: TagMod = EmptyVdom, - remove: TagMod = EmptyVdom, - options: TagMod = EmptyVdom) = { + def renderProject( + project: Project, + artifact: String, + scalaTarget: ScalaTarget, + selected: TagMod, + handlers: TagMod = EmptyVdom, + remove: TagMod = EmptyVdom, + options: TagMod = EmptyVdom + ) = { import project._ val common = TagMod(title := organization, cls := "logo") - val artifact2 = - artifact - .replace(project.repository + "-", "") - .replace(project.repository, "") + val artifact2 = artifact + .replace(project.repository + "-", "") + .replace(project.repository, "") val label = - if (project.repository != artifact) - s"${project.repository} / $artifact2" + if (project.repository != artifact) s"${project.repository} / $artifact2" else artifact - val scaladexLink = - s"https://scaladex.scala-lang.org/$organization/$repository/$artifact" + val scaladexLink = s"https://scaladex.scala-lang.org/$organization/$repository/$artifact" div(cls := "result", selected, handlers)( a(cls := "scaladexresult", href := scaladexLink, target := "_blank")( @@ -378,7 +369,7 @@ object ScaladexSearch { if (scalaTarget.binaryScalaVersion != props.scalaTarget.binaryScalaVersion) span(cls := "artifact")(s"(Scala ${scalaTarget.binaryScalaVersion} artifacts)") else "" - ), + ) ) } @@ -387,7 +378,7 @@ object ScaladexSearch { select( selected.options.versions.reverse.map(v => option(value := v)(v)).toTagMod, value := selected.release.version, - onChange ==> updateVersion(selected), + onChange ==> updateVersion(selected) ) ) } @@ -437,14 +428,16 @@ object ScaladexSearch { val artifact = "toolkit" val versionOpt: Option[String] = None - if (enabled) - addArtifact((toolkitProject, artifact, versionOpt), props.scalaTarget, state, props) + if (enabled) addArtifact((toolkitProject, artifact, versionOpt), props.scalaTarget, state, props) else { - state.value.selecteds.find { selected => - selected.release.groupId == "org.scala-lang" && - selected.release.artifact == "toolkit" && - selected.release.target == props.scalaTarget - }.map(removeSelected).getOrElse(Callback.empty) + state.value.selecteds + .find { selected => + selected.release.groupId == "org.scala-lang" && + selected.release.artifact == "toolkit" && + selected.release.target == props.scalaTarget + } + .map(removeSelected) + .getOrElse(Callback.empty) } } @@ -472,20 +465,19 @@ object ScaladexSearch { ) ), div.withRef(projectListRef)(cls := "results", displayResults)( - state.value.search.zipWithIndex.map { - case ((project, artifact, version, target), index) => - renderProject( - project, - artifact, - target, - selected = selectedIndex(index, state.value.selectedIndex), - handlers = TagMod( - onClick --> addArtifact((project, artifact, version), target, state, props), - onMouseOver --> selectIndex(index) - ) + state.value.search.zipWithIndex.map { case ((project, artifact, version, target), index) => + renderProject( + project, + artifact, + target, + selected = selectedIndex(index, state.value.selectedIndex), + handlers = TagMod( + onClick --> addArtifact((project, artifact, version), target, state, props), + onMouseOver --> selectIndex(index) ) + ) }.toTagMod - ), + ) ) } @@ -495,10 +487,9 @@ object ScaladexSearch { isDarkTheme: Boolean ): VdomElement = { val switchId = s"switch-$label".replace(" ", "-") - val sliderClass = - if (isDarkTheme) "switch-slider dark" else "switch-slider" + val sliderClass = if (isDarkTheme) "switch-slider dark" else "switch-slider" div( - cls := "toolkit-switch", + cls := "toolkit-switch" )( div(cls := "switch")( input( @@ -516,7 +507,7 @@ object ScaladexSearch { ) ), span( - cls := "switch-description", + cls := "switch-description" )(I18n.t("build.enable_toolkit")) ) } @@ -525,22 +516,25 @@ object ScaladexSearch { val target = props.scalaTarget val getProject: ScalaDependency => Future[Option[Selected]] = dependency => { ScaladexSearch.queryAndParse(target, dependency.artifact).flatMap { results => - val possibleMatches = results.filter { - case (project, scalaTarget) => project.artifacts.contains(dependency.artifact) + val possibleMatches = results.filter { case (project, scalaTarget) => + project.artifacts.contains(dependency.artifact) } val possibleProjects = Future.sequence { possibleMatches.map { case (project, scalaTarget) => - ScaladexSearch.fetchSelected( - project, - dependency.artifact, - target, - Some(dependency.version) - ).map(_.toList) + ScaladexSearch + .fetchSelected( + project, + dependency.artifact, + target, + Some(dependency.version) + ) + .map(_.toList) } } - possibleProjects.map(_.flatten.find(_.release.groupId == dependency.groupId)) + possibleProjects + .map(_.flatten.find(_.release.groupId == dependency.groupId)) .recover(_ => None) } } @@ -548,45 +542,47 @@ object ScaladexSearch { val (scastieRuntime, rest) = props.libraries.partition(_.groupId == "org.scastie") val librariesFromList = Future.sequence(rest.toList.map(getProject)).map(_.flatten) - Callback.future { librariesFromList.map { libraries => - Callback.sequence { - val failedLibraries = rest.diff(libraries.map(_.release).toSet) - val removalTask = failedLibraries.map(failed => props.removeScalaDependency(failed)) // + display some kind of popup - val addTask = libraries - .filterNot(library => failedLibraries.contains(library.release)) - .map(selected => state.modState(_.addSelected(selected))) - removalTask.toList ++ addTask + Callback.future { + librariesFromList.map { libraries => + Callback.sequence { + val failedLibraries = rest.diff(libraries.map(_.release).toSet) + val removalTask = + failedLibraries.map(failed => props.removeScalaDependency(failed)) // + display some kind of popup + val addTask = libraries + .filterNot(library => failedLibraries.contains(library.release)) + .map(selected => state.modState(_.addSelected(selected))) + removalTask.toList ++ addTask + } } - }} + } } - private val component = - ScalaFnComponent - .withHooks[ScaladexSearch] - .useState(SearchState.default) - .useEffectOnMountBy((props, state) => updateState(props, state)) - .renderWithReuse((props, state) => render(props, state)) - - // .useLayoutEffectOnMountBy((props, ref, prevProps, editorView) => init(props, ref.value, editorView)) - // .useEffectBy( - // (props, ref, prevProps, editorView) => updateComponent(props, ref.value, prevProps.value, editorView) >> prevProps.set(Some(props)) - // )useEffectBy - // .render((_, ref, _, _) => Editor.render(ref.value))(()) - // . - // .backend(new ScaladexSearchBackend(_)) - // .renderPS(render) - // .componentDidMount( - // props.librariesFrom.toList.sortBy(_._1.artifact).map { lib => - // scope.backend.addArtifact((lib._2, lib._1.artifact, Some(lib._1.version)), lib._1.target, scope.state, localOnly = true) - // } - // ) - // .componentWillReceiveProps { x => - // println("THIS IS SUPER IMPORTANTES") - // println(x) - // Callback.traverse(x.nextProps.librariesFrom.toList.sortBy(_._1.artifact)) { lib => - // x.backend.addArtifact((lib._2, lib._1.artifact, Some(lib._1.version)), lib._1.target, x.state, localOnly = true) - // } - // } - // // .configure(Reusability.shouldComponentUpdate) - // .build + private val component = ScalaFnComponent + .withHooks[ScaladexSearch] + .useState(SearchState.default) + .useEffectOnMountBy((props, state) => updateState(props, state)) + .renderWithReuse((props, state) => render(props, state)) + + // .useLayoutEffectOnMountBy((props, ref, prevProps, editorView) => init(props, ref.value, editorView)) + // .useEffectBy( + // (props, ref, prevProps, editorView) => updateComponent(props, ref.value, prevProps.value, editorView) >> prevProps.set(Some(props)) + // )useEffectBy + // .render((_, ref, _, _) => Editor.render(ref.value))(()) + // . + // .backend(new ScaladexSearchBackend(_)) + // .renderPS(render) + // .componentDidMount( + // props.librariesFrom.toList.sortBy(_._1.artifact).map { lib => + // scope.backend.addArtifact((lib._2, lib._1.artifact, Some(lib._1.version)), lib._1.target, scope.state, localOnly = true) + // } + // ) + // .componentWillReceiveProps { x => + // println("THIS IS SUPER IMPORTANTES") + // println(x) + // Callback.traverse(x.nextProps.librariesFrom.toList.sortBy(_._1.artifact)) { lib => + // x.backend.addArtifact((lib._2, lib._1.artifact, Some(lib._1.version)), lib._1.target, x.state, localOnly = true) + // } + // } + // // .configure(Reusability.shouldComponentUpdate) + // .build } diff --git a/client/src/main/scala/org/scastie/client/components/Scastie.scala b/client/src/main/scala/org/scastie/client/components/Scastie.scala index 9170e9e4d..099d1500c 100644 --- a/client/src/main/scala/org/scastie/client/components/Scastie.scala +++ b/client/src/main/scala/org/scastie/client/components/Scastie.scala @@ -12,21 +12,21 @@ import org.scastie.api._ import org.scastie.client._ final case class Scastie( - router: Option[RouterCtl[Page]], - private val scastieId: UUID, - private val snippetId: Option[SnippetId], - private val oldSnippetId: Option[Int], - private val embedded: Option[EmbeddedOptions], - private val targetType: Option[ScalaTargetType], - private val tryLibrary: Option[(ScalaDependency, Project)], - private val code: Option[String], - private val inputs: Option[BaseInputs] + router: Option[RouterCtl[Page]], + private val scastieId: UUID, + private val snippetId: Option[SnippetId], + private val oldSnippetId: Option[Int], + private val embedded: Option[EmbeddedOptions], + private val targetType: Option[ScalaTargetType], + private val tryLibrary: Option[(ScalaDependency, Project)], + private val code: Option[String], + private val inputs: Option[BaseInputs] ) { @inline def render = Scastie.component(serverUrl, scastieId)(this) def serverUrl: Option[String] = embedded.map(_.serverUrl) - def isEmbedded: Boolean = embedded.isDefined + def isEmbedded: Boolean = embedded.isDefined // todo not sure how is it different from regular snippet id def embeddedSnippetId: Option[SnippetId] = embedded.flatMap(_.snippetId) } @@ -205,8 +205,8 @@ object Scastie { state.snippetState.scalaJsContent.foreach { content => println("== Loading Scala.js! ==") val scalaJsScriptElement = createScript(scalaJsId) - val fixedContent = playgroundMainRegex.replaceAllIn(content, "var ScastiePlaygroundMain") - val scriptTextNode = dom.document.createTextNode(fixedContent) + val fixedContent = playgroundMainRegex.replaceAllIn(content, "var ScastiePlaygroundMain") + val scriptTextNode = dom.document.createTextNode(fixedContent) scalaJsScriptElement.appendChild(scriptTextNode) runScalaJs() } @@ -218,8 +218,8 @@ object Scastie { .builder[Scastie]("Scastie") .initialStateFromProps { props => val state = { - val scheme = LocalStorage.load.map(_.isDarkTheme) - val editorMode = LocalStorage.load.map(_.editorMode) + val scheme = LocalStorage.load.map(_.isDarkTheme) + val editorMode = LocalStorage.load.map(_.editorMode) val loadedState = ScastieState.default(props.isEmbedded).copy(inputs = SbtInputs.default.copy(code = "")) val loadedStateWithMode = editorMode.map(mode => loadedState.copy(editorMode = mode)).getOrElse(loadedState) val loadedStateWithScheme = @@ -280,15 +280,15 @@ object Scastie { executeScalaJs(scastieId, scope.currentState) } .componentWillReceiveProps { scope => - val next = scope.nextProps.snippetId + val next = scope.nextProps.snippetId val current = scope.currentProps.snippetId - val state = scope.state + val state = scope.state val backend = scope.backend val loadSnippet: CallbackOption[Unit] = for { snippetId <- CallbackOption.option(next) - _ <- CallbackOption.require(next != current) - _ <- backend.loadSnippet(snippetId).toCBO >> backend.setView(View.Editor) + _ <- CallbackOption.require(next != current) + _ <- backend.loadSnippet(snippetId).toCBO >> backend.setView(View.Editor) } yield () setTitle(state, scope.nextProps) >> loadSnippet.toCallback diff --git a/client/src/main/scala/org/scastie/client/components/SideBar.scala b/client/src/main/scala/org/scastie/client/components/SideBar.scala index b353ac8b4..4206ce03e 100644 --- a/client/src/main/scala/org/scastie/client/components/SideBar.scala +++ b/client/src/main/scala/org/scastie/client/components/SideBar.scala @@ -2,15 +2,14 @@ package org.scastie package client package components -import org.scastie.api._ -import org.scastie.client.i18n.I18n +import scala.scalajs.js -import japgolly.scalajs.react._ -import vdom.all._ import extra._ - -import scala.scalajs.js +import japgolly.scalajs.react._ import js.annotation._ +import org.scastie.api._ +import org.scastie.client.i18n.I18n +import vdom.all._ @JSImport("@resources/images/icon-scastie.png", JSImport.Default) @js.native @@ -25,23 +24,24 @@ object Assets { def placeholder: String = Placeholder.asInstanceOf[String] } -final case class SideBar(isDarkTheme: Boolean, - status: StatusState, - inputs: BaseInputs, - toggleTheme: Reusable[Callback], - view: StateSnapshot[View], - openHelpModal: Reusable[Callback], - openPrivacyPolicyModal: Reusable[Callback], - editorMode: EditorMode, - setEditorMode: EditorMode => Callback, - language: String) { +final case class SideBar( + isDarkTheme: Boolean, + status: StatusState, + inputs: BaseInputs, + toggleTheme: Reusable[Callback], + view: StateSnapshot[View], + openHelpModal: Reusable[Callback], + openPrivacyPolicyModal: Reusable[Callback], + editorMode: EditorMode, + setEditorMode: EditorMode => Callback, + language: String +) { @inline def render: VdomElement = SideBar.component(this) } object SideBar { - implicit val reusability: Reusability[SideBar] = - Reusability.derive[SideBar] + implicit val reusability: Reusability[SideBar] = Reusability.derive[SideBar] private def render(props: SideBar): VdomElement = { val toggleThemeLabel = @@ -54,17 +54,25 @@ object SideBar { if (props.isDarkTheme) "fa fa-sun-o" else "fa fa-moon-o" - val themeButton = - li(onClick --> props.toggleTheme, role := "button", title := I18n.t(s"sidebar.theme_${theme}_tooltip"), cls := "btn")( - i(cls := s"fa $selectedIcon"), - span(toggleThemeLabel) - ) + val themeButton = li( + onClick --> props.toggleTheme, + role := "button", + title := I18n.t(s"sidebar.theme_${theme}_tooltip"), + cls := "btn" + )( + i(cls := s"fa $selectedIcon"), + span(toggleThemeLabel) + ) - val privacyPolicyButton = - li(onClick --> props.openPrivacyPolicyModal, role := "button", title := I18n.t("sidebar.privacy_policy_tooltip"), cls := "btn")( - i(cls := "fa fa-user-secret"), - span(I18n.t("sidebar.privacy_policy")) - ) + val privacyPolicyButton = li( + onClick --> props.openPrivacyPolicyModal, + role := "button", + title := I18n.t("sidebar.privacy_policy_tooltip"), + cls := "btn" + )( + i(cls := "fa fa-user-secret"), + span(I18n.t("sidebar.privacy_policy")) + ) val helpButton = li(onClick --> props.openHelpModal, role := "button", title := I18n.t("sidebar.help_tooltip"), cls := "btn")( @@ -73,19 +81,20 @@ object SideBar { ) val runnersStatusButton = { - val (statusIcon, statusClass, statusLabel) = - props.status.sbtRunnerCount match { - case None => - ("fa-times-circle", "status-unknown", I18n.t("sidebar.status_unknown")) + val (statusIcon, statusClass, statusLabel) = props.status.sbtRunnerCount match { + case None => ("fa-times-circle", "status-unknown", I18n.t("sidebar.status_unknown")) - case Some(0) => - ("fa-times-circle", "status-down", I18n.t("sidebar.status_down")) + case Some(0) => ("fa-times-circle", "status-down", I18n.t("sidebar.status_down")) - case Some(_) => - ("fa-check-circle", "status-up", I18n.t("sidebar.status_up")) - } + case Some(_) => ("fa-check-circle", "status-up", I18n.t("sidebar.status_up")) + } - li(onClick --> props.view.setState(View.Status), role := "button", title := I18n.t("sidebar.status_tooltip"), cls := s"btn $statusClass")( + li( + onClick --> props.view.setState(View.Status), + role := "button", + title := I18n.t("sidebar.status_tooltip"), + cls := s"btn $statusClass" + )( i(cls := s"fa $statusIcon"), span(statusLabel) ) @@ -107,27 +116,26 @@ object SideBar { onClick = reusableEmpty ).render - val editorModeSelector = - li( - cls := "btn", - i(cls := "fa fa-keyboard-o"), - select( - value := props.editorMode.toString, - cls := s"editor-mode-select ${if (props.isDarkTheme) "dark" else "light"}", - onChange ==> { (e: ReactEventFromInput) => - val mode = e.target.value match { - case "Default" => Default - case "Vim" => Vim - case "Emacs" => Emacs - case _ => Default - } - props.setEditorMode(mode) - }, - option(value := "Default", I18n.t("sidebar.editor_mode_default")), - option(value := "Vim", "Vim"), - option(value := "Emacs", "Emacs") - ) + val editorModeSelector = li( + cls := "btn", + i(cls := "fa fa-keyboard-o"), + select( + value := props.editorMode.toString, + cls := s"editor-mode-select ${if (props.isDarkTheme) "dark" else "light"}", + onChange ==> { (e: ReactEventFromInput) => + val mode = e.target.value match { + case "Default" => Default + case "Vim" => Vim + case "Emacs" => Emacs + case _ => Default + } + props.setEditorMode(mode) + }, + option(value := "Default", I18n.t("sidebar.editor_mode_default")), + option(value := "Vim", "Vim"), + option(value := "Emacs", "Emacs") ) + ) nav(cls := "sidebar")( div(cls := "actions-container")( @@ -150,10 +158,10 @@ object SideBar { ) } - private val component = - ScalaComponent - .builder[SideBar]("SideBar") - .render_P(render) - .configure(Reusability.shouldComponentUpdate) - .build + private val component = ScalaComponent + .builder[SideBar]("SideBar") + .render_P(render) + .configure(Reusability.shouldComponentUpdate) + .build + } diff --git a/client/src/main/scala/org/scastie/client/components/Status.scala b/client/src/main/scala/org/scastie/client/components/Status.scala index b9778cde8..7053daab4 100644 --- a/client/src/main/scala/org/scastie/client/components/Status.scala +++ b/client/src/main/scala/org/scastie/client/components/Status.scala @@ -1,29 +1,32 @@ package org.scastie.client.components -import org.scastie.api._ -import org.scastie.api.TaskId -import org.scastie.client.Page -import org.scastie.client.StatusState -import japgolly.scalajs.react._ - -import vdom.all._ import extra.router._ +import japgolly.scalajs.react._ +import japgolly.scalajs.react.hooks.HookCtx.I18 +import org.scastie.api._ import org.scastie.api.BaseInputs import org.scastie.api.SbtInputs import org.scastie.api.ScalaCliInputs import org.scastie.api.ShortInputs - +import org.scastie.api.TaskId import org.scastie.client.i18n.I18n -import japgolly.scalajs.react.hooks.HookCtx.I18 +import org.scastie.client.Page +import org.scastie.client.StatusState +import vdom.all._ -final case class Status(state: StatusState, router: RouterCtl[Page], isAdmin: Boolean, inputs: BaseInputs, language: String) { +final case class Status( + state: StatusState, + router: RouterCtl[Page], + isAdmin: Boolean, + inputs: BaseInputs, + language: String +) { @inline def render: VdomElement = Status.component(this) } object Status { - implicit val reusability: Reusability[Status] = - Reusability.derive[Status] + implicit val reusability: Reusability[Status] = Reusability.derive[Status] def render(props: Status): VdomElement = { def renderSbtTask(tasks: Vector[TaskId]): VdomElement = { @@ -32,13 +35,12 @@ object Status { div(I18n.t("status.no_task")) } else { ul( - tasks.zipWithIndex.map { - case (TaskId(snippetId), j) => - li(key := snippetId.toString)( - props.router.link(Page.fromSnippetId(snippetId))( - s"${I18n.t("status.task")} $j" - ) + tasks.zipWithIndex.map { case (TaskId(snippetId), j) => + li(key := snippetId.toString)( + props.router.link(Page.fromSnippetId(snippetId))( + s"${I18n.t("status.task")} $j" ) + ) }.toTagMod ) } @@ -48,41 +50,38 @@ object Status { } def renderConfiguration(serverInputs: SbtInputs): VdomElement = { - val (cssConfig, label) = - props.inputs match { - case sbtInputs: SbtInputs if (serverInputs.needsReload(sbtInputs)) => ("needs-reload", I18n.t("status.different_config")) - case _: ScalaCliInputs => ("different-target", I18n.t("status.sbt_runner_config")) - case _ => ("ready", I18n.t("status.same_config")) - } + val (cssConfig, label) = props.inputs match { + case sbtInputs: SbtInputs if (serverInputs.needsReload(sbtInputs)) => + ("needs-reload", I18n.t("status.different_config")) + case _: ScalaCliInputs => ("different-target", I18n.t("status.sbt_runner_config")) + case _ => ("ready", I18n.t("status.same_config")) + } span(cls := "runner " + cssConfig)(label) } - val sbtRunnersStatus = - props.state.sbtRunners match { - case Some(sbtRunners) => - div( - h1(I18n.t("status.sbt_runners")), - ul( - sbtRunners.zipWithIndex.map { - case (sbtRunner, i) => - li(key := i)( - renderConfiguration(sbtRunner.config), - renderSbtTask(sbtRunner.tasks) - ) - }.toTagMod - ) + val sbtRunnersStatus = props.state.sbtRunners match { + case Some(sbtRunners) => div( + h1(I18n.t("status.sbt_runners")), + ul( + sbtRunners.zipWithIndex.map { case (sbtRunner, i) => + li(key := i)( + renderConfiguration(sbtRunner.config), + renderSbtTask(sbtRunner.tasks) + ) + }.toTagMod ) - case _ => div() - } + ) + case _ => div() + } div(sbtRunnersStatus) } - private val component = - ScalaComponent - .builder[Status]("Status") - .render_P(render) - .configure(Reusability.shouldComponentUpdate) - .build + private val component = ScalaComponent + .builder[Status]("Status") + .render_P(render) + .configure(Reusability.shouldComponentUpdate) + .build + } diff --git a/client/src/main/scala/org/scastie/client/components/TargetSelector.scala b/client/src/main/scala/org/scastie/client/components/TargetSelector.scala index 1a3c62695..0f1f5bbe4 100644 --- a/client/src/main/scala/org/scastie/client/components/TargetSelector.scala +++ b/client/src/main/scala/org/scastie/client/components/TargetSelector.scala @@ -1,11 +1,10 @@ package org.scastie.client.components -import org.scastie.api._ import japgolly.scalajs.react._ - -import vdom.all._ -import org.scastie.api.ScalaTargetType.Scala2 +import org.scastie.api._ import org.scastie.api.ScalaTargetType.JS +import org.scastie.api.ScalaTargetType.Scala2 +import vdom.all._ case class TargetSelector(scalaTarget: ScalaTarget, onChange: ScalaTarget ~=> Callback) { @inline def render: VdomElement = TargetSelector.targetSelector(this) @@ -32,28 +31,27 @@ object TargetSelector { } } - - val targetSelector = - ScalaFnComponent - .withHooks[TargetSelector] - .render(props => { - div( - ul(cls := "target")( - targetTypes.map { targetType => - val targetLabel = labelFor(targetType) - li( - input( - `type` := "radio", - id := targetLabel, - value := targetLabel, - name := "target", - onChange --> props.onChange(targetType.defaultScalaTarget), - checked := targetType == props.scalaTarget.targetType - ), - label(`for` := targetLabel, role := "button", cls := "radio", targetLabel) - ) - }.toTagMod - ) + val targetSelector = ScalaFnComponent + .withHooks[TargetSelector] + .render(props => { + div( + ul(cls := "target")( + targetTypes.map { targetType => + val targetLabel = labelFor(targetType) + li( + input( + `type` := "radio", + id := targetLabel, + value := targetLabel, + name := "target", + onChange --> props.onChange(targetType.defaultScalaTarget), + checked := targetType == props.scalaTarget.targetType + ), + label(`for` := targetLabel, role := "button", cls := "radio", targetLabel) + ) + }.toTagMod ) - }) + ) + }) + } diff --git a/client/src/main/scala/org/scastie/client/components/TopBar.scala b/client/src/main/scala/org/scastie/client/components/TopBar.scala index 844e2cc15..fa661aeb2 100644 --- a/client/src/main/scala/org/scastie/client/components/TopBar.scala +++ b/client/src/main/scala/org/scastie/client/components/TopBar.scala @@ -2,73 +2,72 @@ package org.scastie package client package components -import org.scastie.api.User - -import japgolly.scalajs.react._, vdom.all._, extra._ - +import extra._ +import japgolly.scalajs.react._ import org.scalajs.dom - +import org.scastie.api.User import org.scastie.client.i18n.I18n - -final case class TopBar(view: StateSnapshot[View], - user: Option[User], - openLoginModal: Reusable[Callback], - setLanguage: String ~=> Callback, - language: String, - isDarkTheme: Boolean) { +import vdom.all._ + +final case class TopBar( + view: StateSnapshot[View], + user: Option[User], + openLoginModal: Reusable[Callback], + setLanguage: String ~=> Callback, + language: String, + isDarkTheme: Boolean +) { @inline def render: VdomElement = TopBar.component(this) } object TopBar { - implicit val reusability: Reusability[TopBar] = - Reusability.derive[TopBar] + implicit val reusability: Reusability[TopBar] = Reusability.derive[TopBar] private def render(props: TopBar): VdomElement = { - def openInNewTab(link: String): Callback = - Callback { - dom.window.open(link, "_blank").focus() - } + def openInNewTab(link: String): Callback = Callback { + dom.window.open(link, "_blank").focus() + } - def feedback: Callback = - openInNewTab("https://gitter.im/scalacenter/scastie") + def feedback: Callback = openInNewTab("https://gitter.im/scalacenter/scastie") - def issue: Callback = - openInNewTab("https://github.com/scalacenter/scastie/issues/new/choose") + def issue: Callback = openInNewTab("https://github.com/scalacenter/scastie/issues/new/choose") val logoutUrl = "/logout" - def logout: Callback = - props.view.setState(View.Editor) >> - Callback(dom.window.location.pathname = logoutUrl) + def logout: Callback = props.view.setState(View.Editor) >> + Callback(dom.window.location.pathname = logoutUrl) - val profileButton = - props.user match { - case Some(user) => - li( - cls := "btn dropdown", - img(src := user.avatar_url + "&s=30", alt := "Your Github Avatar", cls := "avatar"), - span(user.login), - i(cls := "fa fa-caret-down"), - ul( - cls := "subactions", - li( - onClick --> props.view.setState(View.CodeSnippets), - role := "link", - title := I18n.t("topbar.snippets_tooltip"), - cls := "btn", - (cls := "selected").when(View.CodeSnippets == props.view.value) - )( - i(cls := "fa fa-code"), - I18n.t("topbar.snippets") - ), - li(role := "link", onClick --> logout, cls := "btn", i(cls := "fa fa-sign-out"), I18n.t("topbar.logout")) - ) + val profileButton = props.user match { + case Some(user) => li( + cls := "btn dropdown", + img(src := user.avatar_url + "&s=30", alt := "Your Github Avatar", cls := "avatar"), + span(user.login), + i(cls := "fa fa-caret-down"), + ul( + cls := "subactions", + li( + onClick --> props.view.setState(View.CodeSnippets), + role := "link", + title := I18n.t("topbar.snippets_tooltip"), + cls := "btn", + (cls := "selected").when(View.CodeSnippets == props.view.value) + )( + i(cls := "fa fa-code"), + I18n.t("topbar.snippets") + ), + li(role := "link", onClick --> logout, cls := "btn", i(cls := "fa fa-sign-out"), I18n.t("topbar.logout")) ) + ) - case None => - li(role := "link", onClick --> props.openLoginModal, cls := "btn", i(cls := "fa fa-sign-in"), I18n.t("topbar.login")) - } + case None => li( + role := "link", + onClick --> props.openLoginModal, + cls := "btn", + i(cls := "fa fa-sign-in"), + I18n.t("topbar.login") + ) + } nav( cls := "topbar", @@ -81,18 +80,22 @@ object TopBar { i(cls := "fa fa-caret-down"), ul( cls := "subactions", - li(onClick --> feedback, - role := "link", - title := I18n.t("topbar.feedback_tooltip"), - cls := "btn", - i(cls := "fa fa-gitter"), - span(I18n.t("topbar.gitter"))), - li(onClick --> issue, - role := "link", - title := I18n.t("topbar.github_tooltip"), - cls := "btn", - i(cls := "fa fa-github"), - span(I18n.t("topbar.github_issues"))) + li( + onClick --> feedback, + role := "link", + title := I18n.t("topbar.feedback_tooltip"), + cls := "btn", + i(cls := "fa fa-gitter"), + span(I18n.t("topbar.gitter")) + ), + li( + onClick --> issue, + role := "link", + title := I18n.t("topbar.github_tooltip"), + cls := "btn", + i(cls := "fa fa-github"), + span(I18n.t("topbar.github_issues")) + ) ) ), li( @@ -118,4 +121,5 @@ object TopBar { .render_P(render) .configure(Reusability.shouldComponentUpdate) .build + } diff --git a/client/src/main/scala/org/scastie/client/components/VersionSelector.scala b/client/src/main/scala/org/scastie/client/components/VersionSelector.scala index 18becbce4..d8e7de57f 100644 --- a/client/src/main/scala/org/scastie/client/components/VersionSelector.scala +++ b/client/src/main/scala/org/scastie/client/components/VersionSelector.scala @@ -1,11 +1,10 @@ package org.scastie.client.components +import japgolly.scalajs.react._ import org.scastie.api._ +import org.scastie.buildinfo.BuildInfo import org.scastie.client.i18n.I18n -import japgolly.scalajs.react._ - import vdom.all._ -import org.scastie.buildinfo.BuildInfo case class VersionSelector(scalaTarget: SbtScalaTarget, onChange: ScalaTarget ~=> Callback) { @inline def render: VdomElement = VersionSelector.versionSelectorHook(this) @@ -13,66 +12,70 @@ case class VersionSelector(scalaTarget: SbtScalaTarget, onChange: ScalaTarget ~= object VersionSelector { - val versionSelectorHook = - ScalaFnComponent - .withHooks[VersionSelector] - .render(props => { - def versionSelectors(scalaVersion: String) = - props.scalaTarget match { - case d: Scala2 => Scala2.apply(scalaVersion) - case d: Typelevel => Typelevel.apply(scalaVersion) - case d: Scala3 => Scala3.apply(scalaVersion) - case js: Js => Js(scalaVersion, js.scalaJsVersion) - case n: Native => Native(n.scalaNativeVersion, n.scalaVersion) - } + val versionSelectorHook = ScalaFnComponent + .withHooks[VersionSelector] + .render(props => { + def versionSelectors(scalaVersion: String) = props.scalaTarget match { + case d: Scala2 => Scala2.apply(scalaVersion) + case d: Typelevel => Typelevel.apply(scalaVersion) + case d: Scala3 => Scala3.apply(scalaVersion) + case js: Js => Js(scalaVersion, js.scalaJsVersion) + case n: Native => Native(n.scalaNativeVersion, n.scalaVersion) + } - def renderRecommended3Versions(scalaVersion: String) = { - if (scalaVersion == BuildInfo.stableLTS) s"$scalaVersion LTS" - else if (scalaVersion == BuildInfo.stableNext) s"$scalaVersion Next" - else scalaVersion - } + def renderRecommended3Versions(scalaVersion: String) = { + if (scalaVersion == BuildInfo.stableLTS) s"$scalaVersion LTS" + else if (scalaVersion == BuildInfo.stableNext) s"$scalaVersion Next" + else scalaVersion + } - ul(cls := "suggestedVersions")( - ScalaVersions - .suggestedScalaVersions(props.scalaTarget.targetType) - .map { suggestedVersion => - li( - input( - `type` := "radio", - id := s"scala-$suggestedVersion", - value := suggestedVersion, - name := "scalaV", - onChange --> props.onChange(versionSelectors(suggestedVersion)), - checked := props.scalaTarget.scalaVersion == suggestedVersion - ), - label(`for` := s"scala-$suggestedVersion", className := "radio", role := "button", renderRecommended3Versions(suggestedVersion)) + ul(cls := "suggestedVersions")( + ScalaVersions + .suggestedScalaVersions(props.scalaTarget.targetType) + .map { suggestedVersion => + li( + input( + `type` := "radio", + id := s"scala-$suggestedVersion", + value := suggestedVersion, + name := "scalaV", + onChange --> props.onChange(versionSelectors(suggestedVersion)), + checked := props.scalaTarget.scalaVersion == suggestedVersion + ), + label( + `for` := s"scala-$suggestedVersion", + className := "radio", + role := "button", + renderRecommended3Versions(suggestedVersion) ) - } - .toTagMod, - li( - label( - div(cls := "select-wrapper"){ - val isRecommended = ScalaVersions - .suggestedScalaVersions(props.scalaTarget.targetType) - .contains(props.scalaTarget.scalaVersion) - - select( - name := "scalaVersion", - onChange ==> { (e: ReactEventFromInput) => - props.onChange(versionSelectors(e.target.value)) - }, - value := {if (isRecommended) I18n.t("build.other") else props.scalaTarget.scalaVersion}, - TagMod.when(!isRecommended)(className := "selected-option") - )( - ScalaVersions - .allVersions(props.scalaTarget.targetType) - .map(version => option(version)) - .prepended(option(I18n.t("build.other"))(hidden := true, disabled := true)) - .toTagMod - ) - } ) + } + .toTagMod, + li( + label( + div(cls := "select-wrapper") { + val isRecommended = ScalaVersions + .suggestedScalaVersions(props.scalaTarget.targetType) + .contains(props.scalaTarget.scalaVersion) + + select( + name := "scalaVersion", + onChange ==> { (e: ReactEventFromInput) => + props.onChange(versionSelectors(e.target.value)) + }, + value := { if (isRecommended) I18n.t("build.other") else props.scalaTarget.scalaVersion }, + TagMod.when(!isRecommended)(className := "selected-option") + )( + ScalaVersions + .allVersions(props.scalaTarget.targetType) + .map(version => option(version)) + .prepended(option(I18n.t("build.other"))(hidden := true, disabled := true)) + .toTagMod + ) + } ) ) - }) + ) + }) + } diff --git a/client/src/main/scala/org/scastie/client/components/ViewToggleButton.scala b/client/src/main/scala/org/scastie/client/components/ViewToggleButton.scala index 80caa7dfd..a9e02bde4 100644 --- a/client/src/main/scala/org/scastie/client/components/ViewToggleButton.scala +++ b/client/src/main/scala/org/scastie/client/components/ViewToggleButton.scala @@ -2,20 +2,23 @@ package org.scastie package client package components -import japgolly.scalajs.react._, vdom.all._, extra._ +import extra._ +import japgolly.scalajs.react._ +import vdom.all._ -final case class ViewToggleButton(currentView: StateSnapshot[View], - forView: View, - buttonTitle: String, - faIcon: String, - onClick: Reusable[Callback]) { +final case class ViewToggleButton( + currentView: StateSnapshot[View], + forView: View, + buttonTitle: String, + faIcon: String, + onClick: Reusable[Callback] +) { @inline def render: VdomElement = ViewToggleButton.component(this) } object ViewToggleButton { - implicit val reusability: Reusability[ViewToggleButton] = - Reusability.derive[ViewToggleButton] + implicit val reusability: Reusability[ViewToggleButton] = Reusability.derive[ViewToggleButton] private def render(props: ViewToggleButton): VdomElement = { li( @@ -30,10 +33,10 @@ object ViewToggleButton { ) } - private val component = - ScalaComponent - .builder[ViewToggleButton]("ViewToggleButton") - .render_P(render) - .configure(Reusability.shouldComponentUpdate) - .build + private val component = ScalaComponent + .builder[ViewToggleButton]("ViewToggleButton") + .render_P(render) + .configure(Reusability.shouldComponentUpdate) + .build + } diff --git a/client/src/main/scala/org/scastie/client/components/WorksheetButton.scala b/client/src/main/scala/org/scastie/client/components/WorksheetButton.scala index 90dc1ccd9..7d85ac6a9 100644 --- a/client/src/main/scala/org/scastie/client/components/WorksheetButton.scala +++ b/client/src/main/scala/org/scastie/client/components/WorksheetButton.scala @@ -3,10 +3,8 @@ package client package components import japgolly.scalajs.react._ - -import vdom.all._ - import org.scastie.client.i18n.I18n +import vdom.all._ final case class WorksheetButton( hasWorksheetMode: Boolean, @@ -20,25 +18,19 @@ final case class WorksheetButton( object WorksheetButton { - implicit val reusability: Reusability[WorksheetButton] = - Reusability.derive[WorksheetButton] + implicit val reusability: Reusability[WorksheetButton] = Reusability.derive[WorksheetButton] private def render(props: WorksheetButton): VdomElement = { val isWorksheetModeSelected = if (props.isWorksheetMode) - if (props.view != View.Editor) - TagMod(cls := "enabled alpha") - else - TagMod(cls := "enabled") - else - EmptyVdom + if (props.view != View.Editor) TagMod(cls := "enabled alpha") + else TagMod(cls := "enabled") + else EmptyVdom li( title := (if (props.hasWorksheetMode) - if (props.isWorksheetMode) - I18n.t("editor.worksheet_off_tooltip") - else - I18n.t("editor.worksheet_on_tooltip") + if (props.isWorksheetMode) I18n.t("editor.worksheet_off_tooltip") + else I18n.t("editor.worksheet_on_tooltip") else I18n.t("editor.worksheet_unsupported")), isWorksheetModeSelected, role := "button", @@ -51,10 +43,10 @@ object WorksheetButton { ) } - private val component = - ScalaComponent - .builder[WorksheetButton]("WorksheetButton") - .render_P(render) - .configure(Reusability.shouldComponentUpdate) - .build + private val component = ScalaComponent + .builder[WorksheetButton]("WorksheetButton") + .render_P(render) + .configure(Reusability.shouldComponentUpdate) + .build + } diff --git a/client/src/main/scala/org/scastie/client/components/editor/CodeEditor.scala b/client/src/main/scala/org/scastie/client/components/editor/CodeEditor.scala index 65400614b..eed1195b6 100644 --- a/client/src/main/scala/org/scastie/client/components/editor/CodeEditor.scala +++ b/client/src/main/scala/org/scastie/client/components/editor/CodeEditor.scala @@ -1,13 +1,16 @@ package org.scastie.client.components.editor -import org.scastie.api._ -import org.scastie.runtime.api._ -import org.scastie.client.HTMLFormatter -import org.scastie.client._ +import hooks.Hooks.UseStateF import japgolly.scalajs.react._ +import js.JSConverters._ import org.scalajs.dom import org.scalajs.dom.Element import org.scalajs.dom.HTMLElement +import org.scastie.api._ +import org.scastie.client._ +import org.scastie.client.HTMLFormatter +import org.scastie.runtime.api._ +import scalajs.js import typings.codemirrorAutocomplete.mod._ import typings.codemirrorCommands.mod._ import typings.codemirrorLanguage.mod._ @@ -18,58 +21,57 @@ import typings.codemirrorState.mod._ import typings.codemirrorView.mod._ import typings.replitCodemirrorEmacs.mod.emacs import typings.replitCodemirrorVim.mod.vim - -import scalajs.js import vdom.all._ import JsUtils._ -import hooks.Hooks.UseStateF -import js.JSConverters._ -final case class CodeEditor(visible: Boolean, - isDarkTheme: Boolean, - isPresentationMode: Boolean, - isWorksheetMode: Boolean, - isEmbedded: Boolean, - editorMode: EditorMode, - showLineNumbers: Boolean, - value: String, - attachedDoms: Map[String, HTMLElement], - instrumentations: Set[Instrumentation], - compilationInfos: Set[Problem], - runtimeError: Option[RuntimeError], - saveOrUpdate: Reusable[Callback], - clear: Reusable[Callback], - openNewSnippetModal: Reusable[Callback], - toggleHelp: Reusable[Callback], - toggleConsole: Reusable[Callback], - toggleLineNumbers: Reusable[Callback], - togglePresentationMode: Reusable[Callback], - formatCode: Reusable[Callback], - codeChange: String ~=> Callback, - target: ScalaTarget, - metalsStatus: MetalsStatus, - setMetalsStatus: MetalsStatus ~=> Callback, - updateSettings: ScastieMetalsOptions ~=> Callback, - dependencies: Set[ScalaDependency]) - extends Editor { +final case class CodeEditor( + visible: Boolean, + isDarkTheme: Boolean, + isPresentationMode: Boolean, + isWorksheetMode: Boolean, + isEmbedded: Boolean, + editorMode: EditorMode, + showLineNumbers: Boolean, + value: String, + attachedDoms: Map[String, HTMLElement], + instrumentations: Set[Instrumentation], + compilationInfos: Set[Problem], + runtimeError: Option[RuntimeError], + saveOrUpdate: Reusable[Callback], + clear: Reusable[Callback], + openNewSnippetModal: Reusable[Callback], + toggleHelp: Reusable[Callback], + toggleConsole: Reusable[Callback], + toggleLineNumbers: Reusable[Callback], + togglePresentationMode: Reusable[Callback], + formatCode: Reusable[Callback], + codeChange: String ~=> Callback, + target: ScalaTarget, + metalsStatus: MetalsStatus, + setMetalsStatus: MetalsStatus ~=> Callback, + updateSettings: ScastieMetalsOptions ~=> Callback, + dependencies: Set[ScalaDependency] +) extends Editor { @inline def render: VdomElement = CodeEditor.hooksComponent(this) } object CodeEditor { - - private def init(props: CodeEditor, ref: Ref.Simple[Element], editorView: UseStateF[CallbackTo, EditorView]): Callback = { - if(props.editorMode == Vim) { + private def init( + props: CodeEditor, + ref: Ref.Simple[Element], + editorView: UseStateF[CallbackTo, EditorView] + ): Callback = { + + if (props.editorMode == Vim) { EditorKeymaps.registerVimCommands(props) } - + ref.foreachCB(divRef => { val syntaxHighlighting = new SyntaxHighlightingPlugin(editorView) - val modeExtension: Extension = - getExtension(props.editorMode) - val extensions = - js.Array[Any]( + val modeExtension: Extension = getExtension(props.editorMode) + val extensions = js.Array[Any]( Editor.editorTheme.of(props.codemirrorTheme), Editor.editorModeCompartment.of(modeExtension), lineNumbers(), @@ -85,7 +87,9 @@ object CodeEditor { crosshairCursor(), highlightSelectionMatches(), Editor.indentationMarkersExtension, - keymap.of(closeBracketsKeymap ++ defaultKeymap ++ historyKeymap ++ foldKeymap ++ completionKeymap ++ lintKeymap ++ searchKeymap), + keymap.of( + closeBracketsKeymap ++ defaultKeymap ++ historyKeymap ++ foldKeymap ++ completionKeymap ++ lintKeymap ++ searchKeymap + ), StateField .define(StateFieldSpec[Set[Instrumentation]](_ => props.instrumentations, (value, _) => value)) .extension, @@ -103,9 +107,10 @@ object CodeEditor { .setExtensions(extensions) .setDoc(props.value) - val editor = new EditorView(EditorViewConfig() - .setState(EditorState.create(editorStateConfig)) - .setParent(divRef) + val editor = new EditorView( + EditorViewConfig() + .setState(EditorState.create(editorStateConfig)) + .setParent(divRef) ) editorView.setState(editor) @@ -134,11 +139,11 @@ object CodeEditor { val msg = if (runtimeError.fullStack.nonEmpty) runtimeError.fullStack else runtimeError.message Diagnostic(lineInfo.from, msg, codemirrorLintStrings.error, lineInfo.to) - .setRenderMessage(CallbackTo { - val wrapper = dom.document.createElement("pre") - wrapper.innerHTML = HTMLFormatter.format(msg) - wrapper - }) + .setRenderMessage(CallbackTo { + val wrapper = dom.document.createElement("pre") + wrapper.innerHTML = HTMLFormatter.format(msg) + wrapper + }) }) (errors ++ runtimeErrors).toJSArray @@ -152,23 +157,29 @@ object CodeEditor { } } - private def updateDiagnostics(editorView: UseStateF[CallbackTo, EditorView], prevProps: Option[CodeEditor], props: CodeEditor): Callback = { + private def updateDiagnostics( + editorView: UseStateF[CallbackTo, EditorView], + prevProps: Option[CodeEditor], + props: CodeEditor + ): Callback = { Callback { - editorView.value.dispatch(setDiagnostics(editorView.value.state, getDecorations(props, editorView.value.state.doc))) + editorView.value.dispatch( + setDiagnostics(editorView.value.state, getDecorations(props, editorView.value.state.doc)) + ) }.when_( prevProps.isDefined && props.value == editorView.value.state.doc.toString() && ( - prevProps.get.compilationInfos != props.compilationInfos || - prevProps.get.runtimeError != props.runtimeError - ) + prevProps.get.compilationInfos != props.compilationInfos || + prevProps.get.runtimeError != props.runtimeError + ) ) } private def updateComponent( - props: CodeEditor, - ref: Ref.Simple[Element], - prevProps: Option[CodeEditor], - editorView: UseStateF[CallbackTo, EditorView] + props: CodeEditor, + ref: Ref.Simple[Element], + prevProps: Option[CodeEditor], + editorView: UseStateF[CallbackTo, EditorView] ): Callback = { Editor.updateCode(editorView, props) >> Editor.updateTheme(ref, prevProps, props, editorView) >> @@ -177,29 +188,26 @@ object CodeEditor { InteractiveProvider.reloadMetalsConfiguration(editorView, prevProps, props) } - val hooksComponent = - ScalaFnComponent - .withHooks[CodeEditor] - .useRef(Ref[Element]) - .useRef[Option[CodeEditor]](None) - .useState(new EditorView()) - .useEffectOnMountBy((props, ref, prevProps, editorView) => init(props, ref.value, editorView)) - .useEffectBy( - (props, ref, prevProps, editorView) => - Callback { - if (prevProps.value.exists(_.editorMode != props.editorMode)) { - val modeExtension: Extension = - getExtension(props.editorMode) - editorView.value.dispatch( - TransactionSpec().setEffects( - Editor.editorModeCompartment.reconfigure(modeExtension) - ) - ) - } - } >> - updateComponent(props, ref.value, prevProps.value, editorView) >> - prevProps.set(Some(props)) - ) - .render((_, ref, _, _) => Editor.render(ref.value)) + val hooksComponent = ScalaFnComponent + .withHooks[CodeEditor] + .useRef(Ref[Element]) + .useRef[Option[CodeEditor]](None) + .useState(new EditorView()) + .useEffectOnMountBy((props, ref, prevProps, editorView) => init(props, ref.value, editorView)) + .useEffectBy((props, ref, prevProps, editorView) => + Callback { + if (prevProps.value.exists(_.editorMode != props.editorMode)) { + val modeExtension: Extension = getExtension(props.editorMode) + editorView.value.dispatch( + TransactionSpec().setEffects( + Editor.editorModeCompartment.reconfigure(modeExtension) + ) + ) + } + } >> + updateComponent(props, ref.value, prevProps.value, editorView) >> + prevProps.set(Some(props)) + ) + .render((_, ref, _, _) => Editor.render(ref.value)) } diff --git a/client/src/main/scala/org/scastie/client/components/editor/DebouncingCapabilities.scala b/client/src/main/scala/org/scastie/client/components/editor/DebouncingCapabilities.scala index 9e71a599d..cf7c44702 100644 --- a/client/src/main/scala/org/scastie/client/components/editor/DebouncingCapabilities.scala +++ b/client/src/main/scala/org/scastie/client/components/editor/DebouncingCapabilities.scala @@ -1,15 +1,13 @@ package org.scastie.client.components.editor -import typings.codemirrorState.mod._ -import typings.codemirrorView.mod._ - import scala.concurrent.duration._ import scala.scalajs.js.timers._ import scalajs.js +import typings.codemirrorState.mod._ +import typings.codemirrorView.mod._ import EditorTextOps._ - trait DebouncingCapabilities { type OnChange = (String, EditorView) => Unit @@ -17,13 +15,11 @@ trait DebouncingCapabilities { FacetConfig[OnChange, OnChange]().setCombine(input => over(input.toSeq)) } - private def debounce(fn: OnChange): OnChange = { + private def debounce(fn: OnChange): OnChange = { var timeout: js.UndefOr[js.timers.SetTimeoutHandle] = js.undefined (code: String, view: EditorView) => { - val tokenLength = view - .lineBeforeCursor - .reverseIterator + val tokenLength = view.lineBeforeCursor.reverseIterator .takeWhile(c => !c.isWhitespace || c == '.') .length @@ -38,8 +34,8 @@ trait DebouncingCapabilities { } } - private def over(functions: Seq[OnChange]): OnChange = { - (code: String, view: EditorView) => functions.foreach(f => f(code, view)) + private def over(functions: Seq[OnChange]): OnChange = { (code: String, view: EditorView) => + functions.foreach(f => f(code, view)) } protected def onChangeCallback(onChange: OnChange): Extension = { @@ -56,4 +52,5 @@ trait DebouncingCapabilities { }) ) } + } diff --git a/client/src/main/scala/org/scastie/client/components/editor/DecorationProvider.scala b/client/src/main/scala/org/scastie/client/components/editor/DecorationProvider.scala index 4c2fbc86f..fdfd28745 100644 --- a/client/src/main/scala/org/scastie/client/components/editor/DecorationProvider.scala +++ b/client/src/main/scala/org/scastie/client/components/editor/DecorationProvider.scala @@ -1,22 +1,22 @@ package org.scastie.client.components.editor -import org.scastie.api._ -import org.scastie.runtime.api._ +import scala.collection.mutable.ListBuffer + +import hooks.Hooks.UseStateF import japgolly.scalajs.react._ +import js.JSConverters._ import org.scalajs.dom import org.scalajs.dom.HTMLElement +import org.scastie.api._ +import org.scastie.runtime.api._ +import scalajs.js import typings.codemirrorState.mod._ import typings.codemirrorView.mod._ -import scala.collection.mutable.ListBuffer - -import scalajs.js -import hooks.Hooks.UseStateF -import js.JSConverters._ - object DecorationProvider { final class AttachedDomDecoration(uuid: String, attachedDoms: Map[String, HTMLElement]) extends WidgetType { + override def toDOM(view: EditorView): HTMLElement = { val wrap = dom.document.createElement("div") wrap.setAttribute("aria-hidden", "true") @@ -24,9 +24,11 @@ object DecorationProvider { attachedDoms.get(uuid).map(wrap.append(_)) wrap.domAsHtml } + } final class TypeDecoration(value: String, typeName: String) extends WidgetType { + override def toDOM(view: EditorView): HTMLElement = { val wrap = dom.document.createElement("span") wrap.setAttribute("aria-hidden", "true") @@ -43,9 +45,11 @@ object DecorationProvider { wrap.append(textBody) wrap.domAsHtml } + } final class HTMLDecoration(html: String) extends WidgetType { + override def toDOM(view: EditorView): HTMLElement = { val wrap = dom.document.createElement("pre") wrap.setAttribute("aria-hidden", "true") @@ -53,9 +57,14 @@ object DecorationProvider { wrap.innerHTML = html wrap.domAsHtml } + } - private def createDecorations(instrumentations: Set[Instrumentation], attachedDoms: Map[String, HTMLElement], maxPosititon: Int): DecorationSet = { + private def createDecorations( + instrumentations: Set[Instrumentation], + attachedDoms: Map[String, HTMLElement], + maxPosititon: Int + ): DecorationSet = { val deco = instrumentations .filter(_.position.end < maxPosititon) .map { instrumentation => @@ -101,12 +110,10 @@ object DecorationProvider { }.asInstanceOf[RangeSetUpdate[DecorationSet]]) .map(transaction.changes) - if (decorationsToReAdd.isEmpty) - newValues - else - newValues.update(new js.Object { - var add = decorationsToReAdd.toJSArray - }.asInstanceOf[RangeSetUpdate[DecorationSet]]) + if (decorationsToReAdd.isEmpty) newValues + else newValues.update(new js.Object { + var add = decorationsToReAdd.toJSArray + }.asInstanceOf[RangeSetUpdate[DecorationSet]]) } private def updateState(previousValue: DecorationSet, transaction: Transaction): DecorationSet = { @@ -119,8 +126,7 @@ object DecorationProvider { val decorationSet = stateEffect.value.asInstanceOf[DecorationSet] if (decorationSet.size > 0) decorationSet else Decoration.none } - case _ => - updateDecorationPositions(previousValue, transaction) + case _ => updateDecorationPositions(previousValue, transaction) } } @@ -128,37 +134,36 @@ object DecorationProvider { !ignoredRanges.contains(from) } - private def stateFieldSpec(props: CodeEditor) = - StateFieldSpec[DecorationSet]( - create = _ => createDecorations(props.instrumentations, props.attachedDoms, props.value.length), - update = updateState, - ).setProvide(v => EditorView.decorations.from(v)) + private def stateFieldSpec(props: CodeEditor) = StateFieldSpec[DecorationSet]( + create = _ => createDecorations(props.instrumentations, props.attachedDoms, props.value.length), + update = updateState + ).setProvide(v => EditorView.decorations.from(v)) def updateDecorations( - editorView: UseStateF[CallbackTo, EditorView], - prevProps: Option[CodeEditor], - props: CodeEditor - ): Callback = - Callback { - val decorations = createDecorations(props.instrumentations, props.attachedDoms, editorView.value.state.doc.length.toInt + 1) - val addTypesEffect = addTypeDecorations.of(decorations) - val changes = new js.Object { - var desc = new js.Object { - var length = prevProps.map(_.value.length).getOrElse(0) - var newLength = props.value.length - var empty = newLength == length - }.asInstanceOf[ChangeDesc] - }.asInstanceOf[ChangeSpec] - - editorView.value.dispatch( - TransactionSpec() - .setChanges(changes) - .setEffects(addTypesEffect.asInstanceOf[StateEffect[Any]]) - ) - }.when_( - prevProps.isDefined && - (props.instrumentations != prevProps.get.instrumentations) + editorView: UseStateF[CallbackTo, EditorView], + prevProps: Option[CodeEditor], + props: CodeEditor + ): Callback = Callback { + val decorations = + createDecorations(props.instrumentations, props.attachedDoms, editorView.value.state.doc.length.toInt + 1) + val addTypesEffect = addTypeDecorations.of(decorations) + val changes = new js.Object { + var desc = new js.Object { + var length = prevProps.map(_.value.length).getOrElse(0) + var newLength = props.value.length + var empty = newLength == length + }.asInstanceOf[ChangeDesc] + }.asInstanceOf[ChangeSpec] + + editorView.value.dispatch( + TransactionSpec() + .setChanges(changes) + .setEffects(addTypesEffect.asInstanceOf[StateEffect[Any]]) ) + }.when_( + prevProps.isDefined && + (props.instrumentations != prevProps.get.instrumentations) + ) def apply(props: CodeEditor): Extension = StateField.define(stateFieldSpec(props)).extension } diff --git a/client/src/main/scala/org/scastie/client/components/editor/Editor.scala b/client/src/main/scala/org/scastie/client/components/editor/Editor.scala index e99abfe8b..94e08fe86 100644 --- a/client/src/main/scala/org/scastie/client/components/editor/Editor.scala +++ b/client/src/main/scala/org/scastie/client/components/editor/Editor.scala @@ -3,13 +3,12 @@ package org.scastie.client.components.editor import japgolly.scalajs.react._ import org.scalablytyped.runtime.StringDictionary import org.scalajs.dom.Element +import scalajs.js import typings.codemirrorState.mod._ import typings.codemirrorView.anon import typings.codemirrorView.mod._ import typings.replitCodemirrorIndentationMarkers.anon.ActiveDark import typings.replitCodemirrorIndentationMarkers.mod._ - -import scalajs.js import vdom.all._ trait Editor { @@ -36,14 +35,18 @@ object Editor { def render(ref: Ref.Simple[Element]): VdomElement = div(cls := "editor-wrapper cm-s-solarized cm-s-light").withRef(ref) - def updateTheme(ref: Ref.Simple[Element], prevProps: Option[Editor], props: Editor, editorView: hooks.Hooks.UseStateF[CallbackTo, EditorView]): Callback = - ref - .foreach(ref => { - val cssTheme = if (props.isDarkTheme) "dark" else "light" - editorView.value.dispatch(TransactionSpec().setEffects(editorTheme.reconfigure(props.codemirrorTheme))) - ref.setAttribute("class", s"editor-wrapper cm-s-solarized cm-s-$cssTheme") - }) - .when_(prevProps.map(_.isDarkTheme != props.isDarkTheme).getOrElse(true)) + def updateTheme( + ref: Ref.Simple[Element], + prevProps: Option[Editor], + props: Editor, + editorView: hooks.Hooks.UseStateF[CallbackTo, EditorView] + ): Callback = ref + .foreach(ref => { + val cssTheme = if (props.isDarkTheme) "dark" else "light" + editorView.value.dispatch(TransactionSpec().setEffects(editorTheme.reconfigure(props.codemirrorTheme))) + ref.setAttribute("class", s"editor-wrapper cm-s-solarized cm-s-$cssTheme") + }) + .when_(prevProps.map(_.isDarkTheme != props.isDarkTheme).getOrElse(true)) def updateCode(editorView: Hooks.UseStateF[CallbackTo, EditorView], newState: Editor): Callback = { Callback { diff --git a/client/src/main/scala/org/scastie/client/components/editor/EditorKeymaps.scala b/client/src/main/scala/org/scastie/client/components/editor/EditorKeymaps.scala index c59ec4b92..d1da08a91 100644 --- a/client/src/main/scala/org/scastie/client/components/editor/EditorKeymaps.scala +++ b/client/src/main/scala/org/scastie/client/components/editor/EditorKeymaps.scala @@ -67,15 +67,15 @@ object EditorKeymaps { ) } - val saveOrUpdate = new Key("Ctrl-Enter", "Meta-Enter") - val saveOrUpdateAlt = new Key("Ctrl-s", "Meta-s") + val saveOrUpdate = new Key("Ctrl-Enter", "Meta-Enter") + val saveOrUpdateAlt = new Key("Ctrl-s", "Meta-s") val openNewSnippetModal = new Key("Ctrl-m", "Meta-m") - val clear = new Key("Escape") - val clearAlt = new Key("F1") - val console = new Key("F3") - val help = new Key("F5") - val format = new Key("F6") - val presentation = new Key("F8") + val clear = new Key("Escape") + val clearAlt = new Key("F1") + val console = new Key("F3") + val help = new Key("F5") + val format = new Key("F6") + val presentation = new Key("F8") def keymapping(e: CodeEditor) = { val base = js.Array( diff --git a/client/src/main/scala/org/scastie/client/components/editor/EditorTextOps.scala b/client/src/main/scala/org/scastie/client/components/editor/EditorTextOps.scala index 7e27757b1..046ff2662 100644 --- a/client/src/main/scala/org/scastie/client/components/editor/EditorTextOps.scala +++ b/client/src/main/scala/org/scastie/client/components/editor/EditorTextOps.scala @@ -2,11 +2,11 @@ package org.scastie.client.components.editor import typings.codemirrorView.mod._ - object EditorTextOps { val regex = """\.\w*|\w+""".r implicit class EditorTextOpsOps(view: EditorView) { + def lineBeforeCursor: String = { val pos = view.state.selection.main.from val line = view.state.doc.lineAt(pos) @@ -20,4 +20,5 @@ object EditorTextOps { } } + } diff --git a/client/src/main/scala/org/scastie/client/components/editor/InteractiveProvider.scala b/client/src/main/scala/org/scastie/client/components/editor/InteractiveProvider.scala index 8ef0e286b..6aeb85e63 100644 --- a/client/src/main/scala/org/scastie/client/components/editor/InteractiveProvider.scala +++ b/client/src/main/scala/org/scastie/client/components/editor/InteractiveProvider.scala @@ -1,30 +1,31 @@ package org.scastie.client.components.editor +import scala.util.Try + +import hooks.Hooks.UseStateF +import japgolly.scalajs.react._ import org.scastie.api import org.scastie.client._ -import japgolly.scalajs.react._ +import org.scastie.client.scalacli.ScalaCliUtils +import scalajs.js import typings.codemirrorState.mod._ import typings.codemirrorView.mod._ import typings.highlightJs.mod.{HighlightOptions => HLJSOptions} -import typings.markedHighlight.mod._ import typings.marked.mod.marked.MarkedExtension - -import scala.util.Try - -import scalajs.js -import hooks.Hooks.UseStateF -import org.scastie.client.scalacli.ScalaCliUtils +import typings.markedHighlight.mod._ case class InteractiveProvider( - dependencies: Set[api.ScalaDependency], - target: api.ScalaTarget, - code: String, - metalsStatus: MetalsStatus, - updateStatus: MetalsStatus ~=> Callback, - updateSettings: api.ScastieMetalsOptions ~=> Callback, - isWorksheetMode: Boolean, - isEmbedded: Boolean, -) extends MetalsClient with MetalsAutocompletion with MetalsHover { + dependencies: Set[api.ScalaDependency], + target: api.ScalaTarget, + code: String, + metalsStatus: MetalsStatus, + updateStatus: MetalsStatus ~=> Callback, + updateSettings: api.ScastieMetalsOptions ~=> Callback, + isWorksheetMode: Boolean, + isEmbedded: Boolean +) extends MetalsClient + with MetalsAutocompletion + with MetalsHover { def extension: js.Array[Any] = js.Array[Any](metalsHover, metalsAutocomplete) @@ -48,9 +49,10 @@ object InteractiveProvider { val interactive = new Compartment() val highlightJS = typings.highlightJs.mod.default + val highlightF: (String, String, String) => String = (str, lang, _) => { if (lang != null && highlightJS.getLanguage(lang) != null && lang != "") { - Try { highlightJS.highlight(str, HLJSOptions(lang)).value}.getOrElse(str) + Try { highlightJS.highlight(str, HLJSOptions(lang)).value }.getOrElse(str) } else { str } @@ -58,24 +60,25 @@ object InteractiveProvider { val marked = typings.marked.mod.marked.`package` marked.use(markedHighlight(SynchronousOptions.apply(highlightF)).asInstanceOf[MarkedExtension]) - marked.setOptions(typings.marked.mod.marked.MarkedOptions() - .setHeaderIds(false) - .setMangle(false) + marked.setOptions( + typings.marked.mod.marked + .MarkedOptions() + .setHeaderIds(false) + .setMangle(false) ) private def wasMetalsToggled(prevProps: CodeEditor, props: CodeEditor): Boolean = (prevProps.metalsStatus == MetalsDisabled && props.metalsStatus == MetalsLoading) || - (prevProps.metalsStatus != MetalsDisabled && props.metalsStatus == MetalsDisabled) + (prevProps.metalsStatus != MetalsDisabled && props.metalsStatus == MetalsDisabled) private def requiresDirectiveReload(prevProps: CodeEditor, props: CodeEditor): Boolean = (prevProps.metalsStatus != OutdatedScalaCli && props.metalsStatus == OutdatedScalaCli) - private def takeDirectives(code: String) = - code.split("\n").takeWhile(_.startsWith("//>")).toList + private def takeDirectives(code: String) = code.split("\n").takeWhile(_.startsWith("//>")).toList import scala.concurrent.duration._ - import scala.scalajs.js.timers._ import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue + import scala.scalajs.js.timers._ val didDirectivesChange: (Option[CodeEditor], CodeEditor) => Unit = { var timeout: js.UndefOr[js.timers.SetTimeoutHandle] = js.undefined @@ -85,25 +88,26 @@ object InteractiveProvider { if (originalPrevious.isEmpty && prev.isDefined) originalPrevious = prev timeout.foreach(clearTimeout) timeout = setTimeout(3000.millis) { - originalPrevious.map { prev => { - val previousDirectives = takeDirectives(prev.value) - val newDirectives = takeDirectives(current.value) - originalPrevious = Some(current) - if (previousDirectives != newDirectives){ - ScalaCliUtils.parse(newDirectives).foreach { case (scalaTarget, dependencies) => - val options = api.ScastieMetalsOptions(dependencies, scalaTarget, current.value) - current.updateSettings(options).runNow() - current.setMetalsStatus(OutdatedScalaCli).runNow() + originalPrevious.map { prev => + { + val previousDirectives = takeDirectives(prev.value) + val newDirectives = takeDirectives(current.value) + originalPrevious = Some(current) + if (previousDirectives != newDirectives) { + ScalaCliUtils.parse(newDirectives).foreach { case (scalaTarget, dependencies) => + val options = api.ScastieMetalsOptions(dependencies, scalaTarget, current.value) + current.updateSettings(options).runNow() + current.setMetalsStatus(OutdatedScalaCli).runNow() + } } } - }} + } } } - private def didConfigChange(prevProps: CodeEditor, props: CodeEditor): Boolean = - props.target != prevProps.target || - props.dependencies != prevProps.dependencies || - props.isWorksheetMode != prevProps.isWorksheetMode + private def didConfigChange(prevProps: CodeEditor, props: CodeEditor): Boolean = props.target != prevProps.target || + props.dependencies != prevProps.dependencies || + props.isWorksheetMode != prevProps.isWorksheetMode def reloadMetalsConfiguration( editorView: UseStateF[CallbackTo, EditorView], diff --git a/client/src/main/scala/org/scastie/client/components/editor/MetalsAutocompletion.scala b/client/src/main/scala/org/scastie/client/components/editor/MetalsAutocompletion.scala index ce120b434..60a744065 100644 --- a/client/src/main/scala/org/scastie/client/components/editor/MetalsAutocompletion.scala +++ b/client/src/main/scala/org/scastie/client/components/editor/MetalsAutocompletion.scala @@ -1,20 +1,19 @@ package org.scastie.client.components.editor -import org.scastie.api +import scala.collection.mutable.HashMap +import scala.concurrent.Future + import japgolly.scalajs.react._ +import js.JSConverters._ import org.scalajs.dom +import org.scastie.api +import scalajs.concurrent.JSExecutionContext.Implicits.queue +import scalajs.js +import scalajs.js.Thenable.Implicits._ import typings.codemirrorAutocomplete.anon import typings.codemirrorAutocomplete.mod._ import typings.codemirrorState.mod._ import typings.codemirrorView.mod._ - -import scala.collection.mutable.HashMap -import scala.concurrent.Future - -import scalajs.js -import scalajs.concurrent.JSExecutionContext.Implicits.queue -import scalajs.js.Thenable.Implicits._ -import js.JSConverters._ import EditorTextOps._ trait MetalsAutocompletion extends MetalsClient with DebouncingCapabilities { @@ -29,44 +28,59 @@ trait MetalsAutocompletion extends MetalsClient with DebouncingCapabilities { /* * Creates additionalInsertInstructions e.g autoimport for completions */ - private def createAdditionalTextEdits(insertInstructions: List[api.AdditionalInsertInstructions], view: EditorView): Seq[ChangeSpec] = { + private def createAdditionalTextEdits( + insertInstructions: List[api.AdditionalInsertInstructions], + view: EditorView + ): Seq[ChangeSpec] = { insertInstructions.map(textEdit => { val editRange = textEdit.editRange val startPos = view.state.doc.line(editRange.startLine).from.toInt + editRange.startChar val endPos = view.state.doc.line(editRange.endLine).from.toInt + editRange.endChar - js.Dynamic.literal( - from = startPos, - to = endPos, - insert = textEdit.text - ).asInstanceOf[ChangeSpec] + js.Dynamic + .literal( + from = startPos, + to = endPos, + insert = textEdit.text + ) + .asInstanceOf[ChangeSpec] }) } private def prepareInsertionText(completion: api.CompletionItemDTO, lineStart: Int): (EditorSelection, String) = { val patternIndex = completion.instructions.text.indexOf("$0") val partiallyCleanedPattern = completion.instructions.text.replace("$0", "") - val offset = if (patternIndex == -1) { - partiallyCleanedPattern.length - } else { - patternIndex - } + val offset = + if (patternIndex == -1) { + partiallyCleanedPattern.length + } else { + patternIndex + } val simpleSelection = EditorSelection.single(lineStart + offset) - selectionPattern.findFirstMatchIn(partiallyCleanedPattern).map { regexMatch => { - val offset = regexMatch.group(0).length - regexMatch.group(1).length - val selection = EditorSelection.single(lineStart + regexMatch.start, lineStart + regexMatch.end - offset) - val adjustedInsertString = partiallyCleanedPattern.substring(0, regexMatch.start) + - regexMatch.group(1) + - partiallyCleanedPattern.substring(regexMatch.end, partiallyCleanedPattern.length) - - (selection, adjustedInsertString) - }}.getOrElse(simpleSelection, partiallyCleanedPattern) + selectionPattern + .findFirstMatchIn(partiallyCleanedPattern) + .map { regexMatch => + { + val offset = regexMatch.group(0).length - regexMatch.group(1).length + val selection = EditorSelection.single(lineStart + regexMatch.start, lineStart + regexMatch.end - offset) + val adjustedInsertString = partiallyCleanedPattern.substring(0, regexMatch.start) + + regexMatch.group(1) + + partiallyCleanedPattern.substring(regexMatch.end, partiallyCleanedPattern.length) + + (selection, adjustedInsertString) + } + } + .getOrElse(simpleSelection, partiallyCleanedPattern) } /* * Creates edit transaction for completion. This enables cursor to be in proper possition after completion is accpeted */ - private def createEditTransaction(view: EditorView, completion: api.CompletionItemDTO, currentCursorPosition: Int): TransactionSpec = { + private def createEditTransaction( + view: EditorView, + completion: api.CompletionItemDTO, + currentCursorPosition: Int + ): TransactionSpec = { val startLinePos = view.state.doc.line(completion.instructions.editRange.startLine).from val endLinePos = view.state.doc.line(completion.instructions.editRange.endLine).from val fromPos = startLinePos + completion.instructions.editRange.startChar @@ -77,13 +91,17 @@ trait MetalsAutocompletion extends MetalsClient with DebouncingCapabilities { val (selection, insertText) = prepareInsertionText(completion, newCursorStartLine.toInt) - TransactionSpec().setChangesVarargs( - (js.Dynamic.literal( - from = fromPos.toDouble, - to = toPos.toDouble max currentCursorPosition, - insert = insertText - ).asInstanceOf[ChangeSpec] +: createAdditionalTextEdits(completion.additionalInsertInstructions, view)):_* - ).setSelection(selection) + TransactionSpec() + .setChangesVarargs( + (js.Dynamic + .literal( + from = fromPos.toDouble, + to = toPos.toDouble max currentCursorPosition, + insert = insertText + ) + .asInstanceOf[ChangeSpec] +: createAdditionalTextEdits(completion.additionalInsertInstructions, view)): _* + ) + .setSelection(selection) } type CompletionInfoF = js.Function1[Completion, js.Promise[dom.Node]] @@ -93,18 +111,22 @@ trait MetalsAutocompletion extends MetalsClient with DebouncingCapabilities { */ private def getCompletionInfo(completionItemDTO: api.CompletionItemDTO): CompletionInfoF = { val key = completionItemDTO.symbol.getOrElse(completionItemDTO.label) - lazy val maybeCachedResult = completionInfoCache.get(key) + lazy val maybeCachedResult = completionInfoCache + .get(key) .map(node => js.Promise.resolve[dom.Node](node)) .getOrElse { - makeRequest(api.CompletionInfoRequest(scastieMetalsOptions, completionItemDTO), "completionItemResolve") - .map { maybeText => - parseMetalsResponse[String](maybeText).filter(_.nonEmpty).map { completionInfo => - val node = dom.document.createElement("div") - node.innerHTML = InteractiveProvider.marked(completionInfo) - completionInfoCache.put(key, node) - node - }.getOrElse(null) - }.toJSPromise + makeRequest(api.CompletionInfoRequest(scastieMetalsOptions, completionItemDTO), "completionItemResolve").map { + maybeText => + parseMetalsResponse[String](maybeText) + .filter(_.nonEmpty) + .map { completionInfo => + val node = dom.document.createElement("div") + node.innerHTML = InteractiveProvider.marked(completionInfo) + completionInfoCache.put(key, node) + node + } + .getOrElse(null) + }.toJSPromise } val result: CompletionInfoF = (completion: Completion) => maybeCachedResult @@ -117,8 +139,8 @@ trait MetalsAutocompletion extends MetalsClient with DebouncingCapabilities { if (!matchesPreviousToken) wasPreviousIncomplete = true }) - private val completionsF: js.Function1[CompletionContext, js.Promise[CompletionResult]] = { - ctx => ifSupported { + private val completionsF: js.Function1[CompletionContext, js.Promise[CompletionResult]] = { ctx => + ifSupported { val word = ctx.matchBefore(jsRegex).asInstanceOf[anon.Text] if (!ctx.explicit || (word == null || word.text.isEmpty || (word.from == word.to))) { @@ -133,8 +155,15 @@ trait MetalsAutocompletion extends MetalsClient with DebouncingCapabilities { makeRequest(request, "complete").map(maybeText => parseMetalsResponse[api.ScalaCompletionList](maybeText).map { completionList => val completions = completionList.items.map { - case cmp @ api.CompletionItemDTO(name, detail, tpe, boost, insertInstructions, additionalInsertInstructions, symbol) => - Completion(name.stripSuffix(detail)) + case cmp @ api.CompletionItemDTO( + name, + detail, + tpe, + boost, + insertInstructions, + additionalInsertInstructions, + symbol + ) => Completion(name.stripSuffix(detail)) .setDetail(detail) .setInfo(getCompletionInfo(cmp)) .setType(tpe) @@ -142,8 +171,7 @@ trait MetalsAutocompletion extends MetalsClient with DebouncingCapabilities { .setApplyFunction4((view, _, from, to) => { wasPreviousIncomplete = false Callback(view.dispatch(createEditTransaction(view, cmp, to.toInt))) - } - ) + }) } wasPreviousIncomplete = completionList.isIncomplete val result = CompletionResult(from, completions.toJSArray) @@ -158,12 +186,15 @@ trait MetalsAutocompletion extends MetalsClient with DebouncingCapabilities { private val autocompletionConfig = CompletionConfig() .setInteractionDelay(0) // we want completions to work instantly .setOverrideVarargs(completionsF) - .setActivateOnTyping(false) // we use our own autocompletion trigger with working debounce MetalsAutocompletion.autocompletionTrigger + .setActivateOnTyping( + false + ) // we use our own autocompletion trigger with working debounce MetalsAutocompletion.autocompletionTrigger .setIcons(true) .setDefaultKeymap(true) def metalsAutocomplete: js.Array[Any] = js.Array[Any]( autocompletion(autocompletionConfig), - autocompletionTrigger, + autocompletionTrigger ) + } diff --git a/client/src/main/scala/org/scastie/client/components/editor/MetalsClient.scala b/client/src/main/scala/org/scastie/client/components/editor/MetalsClient.scala index 941db52c5..739deae2e 100644 --- a/client/src/main/scala/org/scastie/client/components/editor/MetalsClient.scala +++ b/client/src/main/scala/org/scastie/client/components/editor/MetalsClient.scala @@ -1,23 +1,21 @@ package org.scastie.client.components.editor -import org.scastie.api._ -import org.scastie.client._ -import japgolly.scalajs.react._ -import org.scalajs.dom - import scala.concurrent.Future import scala.util.Failure import scala.util.Success -import scalajs.js -import scalajs.concurrent.JSExecutionContext.Implicits.queue -import scalajs.js.Thenable.Implicits._ -import js.JSConverters._ - import io.circe._ +import io.circe.disjunctionCodecs.decoderEither import io.circe.parser._ import io.circe.syntax._ -import io.circe.disjunctionCodecs.decoderEither +import japgolly.scalajs.react._ +import js.JSConverters._ +import org.scalajs.dom +import org.scastie.api._ +import org.scastie.client._ +import scalajs.concurrent.JSExecutionContext.Implicits.queue +import scalajs.js +import scalajs.js.Thenable.Implicits._ trait MetalsClient { val updateStatus: MetalsStatus ~=> Callback @@ -30,17 +28,17 @@ trait MetalsClient { val code: String val scastieMetalsOptions = ScastieMetalsOptions(dependencies, target, code) - private val isConfigurationSupported: Future[Boolean] = { if (metalsStatus == MetalsDisabled || isEmbedded) Future.successful(false) else { updateStatus(MetalsLoading).runNow() val res = makeRequest(scastieMetalsOptions, "isConfigurationSupported").map(maybeText => - parseMetalsResponse[Boolean](maybeText).getOrElse(false)) + parseMetalsResponse[Boolean](maybeText).getOrElse(false) + ) res.onComplete { - case Success(true) => updateStatus(MetalsReady).runNow() + case Success(true) => updateStatus(MetalsReady).runNow() case Failure(exception) => updateStatus(NetworkError(exception.getMessage)).runNow() - case _ => + case _ => } res } @@ -50,15 +48,17 @@ trait MetalsClient { * Runs function `f` only when current scastie configuration is supported. */ protected def ifSupported[A](f: => Future[Option[A]]): js.Promise[Option[A]] = { - isConfigurationSupported.flatMap(isSupported => { - if (isSupported) { - updateStatus(MetalsLoading).runNow() - val res = f.map(Option(_)) - res.onComplete(_ => updateStatus(MetalsReady).runNow()) - res - } else - Future.successful(None) - }).map(_.flatten).toJSPromise + isConfigurationSupported + .flatMap(isSupported => { + if (isSupported) { + updateStatus(MetalsLoading).runNow() + val res = f.map(Option(_)) + res.onComplete(_ => updateStatus(MetalsReady).runNow()) + res + } else Future.successful(None) + }) + .map(_.flatten) + .toJSPromise } protected def toLSPRequest(code: String, offset: Int): LSPRequestDTO = { @@ -66,18 +66,26 @@ trait MetalsClient { LSPRequestDTO(scastieMetalsOptions, offsetParams) } - protected def makeRequest[A](req: A, endpoint: String)(implicit writes: Encoder[A]): Future[Option[String]] = { + protected def makeRequest[A](req: A, endpoint: String)( + implicit writes: Encoder[A] + ): Future[Option[String]] = { val location = dom.window.location // this is workaround until we migrate all services to proper docker setup or unify the servers - val apiBase = if (location.hostname == "localhost") { - location.protocol ++ "//" ++ location.hostname + ":" ++ "8000" - } else "" + val apiBase = + if (location.hostname == "localhost") { + location.protocol ++ "//" ++ location.hostname + ":" ++ "8000" + } else "" // We don't support metals in embedded so we don't need to map server url - val request = dom.fetch(s"$apiBase/metals/$endpoint", js.Dynamic.literal( - body = req.asJson.noSpaces, - method = dom.HttpMethod.POST - ).asInstanceOf[dom.RequestInit]) + val request = dom.fetch( + s"$apiBase/metals/$endpoint", + js.Dynamic + .literal( + body = req.asJson.noSpaces, + method = dom.HttpMethod.POST + ) + .asInstanceOf[dom.RequestInit] + ) for { res <- request @@ -91,7 +99,9 @@ trait MetalsClient { } } - protected def parseMetalsResponse[A](maybeJsonText: Option[String])(implicit readsB: Decoder[A]): Option[A] = { + protected def parseMetalsResponse[A](maybeJsonText: Option[String])( + implicit readsB: Decoder[A] + ): Option[A] = { maybeJsonText.flatMap(jsonText => { decode[Either[FailureType, A]](jsonText).toOption.flatMap { case Left(err) => @@ -101,4 +111,5 @@ trait MetalsClient { } }) } + } diff --git a/client/src/main/scala/org/scastie/client/components/editor/MetalsHover.scala b/client/src/main/scala/org/scastie/client/components/editor/MetalsHover.scala index 09f5f66f5..350aecbb5 100644 --- a/client/src/main/scala/org/scastie/client/components/editor/MetalsHover.scala +++ b/client/src/main/scala/org/scastie/client/components/editor/MetalsHover.scala @@ -1,36 +1,38 @@ package org.scastie.client.components.editor -import org.scastie.api import japgolly.scalajs.react._ +import js.JSConverters._ import org.scalajs.dom -import typings.codemirrorState.mod._ -import typings.codemirrorView.mod._ - -import scalajs.js +import org.scastie.api import scalajs.concurrent.JSExecutionContext.Implicits.queue +import scalajs.js import scalajs.js.Thenable.Implicits._ -import js.JSConverters._ +import typings.codemirrorState.mod._ +import typings.codemirrorView.mod._ trait MetalsHover extends MetalsClient { - private val hovers = hoverTooltip((view, pos, _) => ifSupported { - val request = toLSPRequest(view.state.doc.toString(), pos.toInt) - makeRequest(request, "hover").map(maybeText => - parseMetalsResponse[api.HoverDTO](maybeText).map { hover => - val hoverF: js.Function1[EditorView, TooltipView] = _ => { - val node = dom.document.createElement("div") - node.innerHTML = InteractiveProvider.marked(hover.content) - TooltipView(node.domToHtml.get) - } + private val hovers = hoverTooltip((view, pos, _) => + ifSupported { + val request = toLSPRequest(view.state.doc.toString(), pos.toInt) + + makeRequest(request, "hover").map(maybeText => + parseMetalsResponse[api.HoverDTO](maybeText).map { hover => + val hoverF: js.Function1[EditorView, TooltipView] = _ => { + val node = dom.document.createElement("div") + node.innerHTML = InteractiveProvider.marked(hover.content) + TooltipView(node.domToHtml.get) + } - view.state.wordAt(pos) match { - case range: SelectionRange => Tooltip(hoverF, range.from) - .setEnd(range.to) - case _ => Tooltip(hoverF, pos) + view.state.wordAt(pos) match { + case range: SelectionRange => Tooltip(hoverF, range.from) + .setEnd(range.to) + case _ => Tooltip(hoverF, pos) + } } - } - ) - }.map(_.getOrElse(null)).toJSPromise) + ) + }.map(_.getOrElse(null)).toJSPromise + ) def metalsHover = hovers } diff --git a/client/src/main/scala/org/scastie/client/components/editor/OnChangeHandler.scala b/client/src/main/scala/org/scastie/client/components/editor/OnChangeHandler.scala index cea22ef55..ac823a73b 100644 --- a/client/src/main/scala/org/scastie/client/components/editor/OnChangeHandler.scala +++ b/client/src/main/scala/org/scastie/client/components/editor/OnChangeHandler.scala @@ -1,11 +1,10 @@ package org.scastie.client.components.editor import japgolly.scalajs.react._ +import scalajs.js import typings.codemirrorState.mod._ import typings.codemirrorView.mod._ -import scalajs.js - class OnChangeHandler(onChange: String ~=> Callback) extends js.Object { private def scalaUpdate: js.Function1[ViewUpdate, Unit] = viewUpdate => { @@ -19,6 +18,5 @@ class OnChangeHandler(onChange: String ~=> Callback) extends js.Object { } object OnChangeHandler { - def apply(onChange: String ~=> Callback): Extension = - ViewPlugin.define(_ => new OnChangeHandler(onChange)).extension + def apply(onChange: String ~=> Callback): Extension = ViewPlugin.define(_ => new OnChangeHandler(onChange)).extension } diff --git a/client/src/main/scala/org/scastie/client/components/editor/SimpleEditor.scala b/client/src/main/scala/org/scastie/client/components/editor/SimpleEditor.scala index 72dd50031..d90855b37 100644 --- a/client/src/main/scala/org/scastie/client/components/editor/SimpleEditor.scala +++ b/client/src/main/scala/org/scastie/client/components/editor/SimpleEditor.scala @@ -1,75 +1,78 @@ package org.scastie.client.components.editor -import org.scastie.client.components.editor.OnChangeHandler +import hooks.Hooks.UseStateF import japgolly.scalajs.react._ import org.scalajs.dom.Element +import org.scastie.client.components.editor.OnChangeHandler +import scalajs.js import typings.codemirrorLanguage.mod import typings.codemirrorState.mod._ import typings.codemirrorView.mod._ - -import scalajs.js import vdom.all._ -import hooks.Hooks.UseStateF final case class SimpleEditor( readOnly: Boolean, value: String, isDarkTheme: Boolean, - onChange: String ~=> Callback, + onChange: String ~=> Callback ) extends Editor { @inline def render: VdomElement = SimpleEditor.hooksComponent(this) } object SimpleEditor { - private def init(props: SimpleEditor, ref: Ref.Simple[Element], editorView: UseStateF[CallbackTo, EditorView]): Callback = - ref.foreachCB(divRef => { - val basicExtensions = js.Array[Any]( - Editor.editorTheme.of(props.codemirrorTheme), - Editor.indentationMarkersExtension, - typings.codemirror.mod.minimalSetup, - mod.StreamLanguage.define(typings.codemirrorLegacyModes.modeClikeMod.scala_), - SyntaxHighlightingTheme.highlightingTheme, - ) - lazy val readOnlyExtensions = js.Array[Any]( - EditorState.readOnly.of(true), - ) - lazy val editableExtensions = js.Array[Any]( - lineNumbers(), - OnChangeHandler(props.onChange), - ) - val editorStateConfig = EditorStateConfig() - .setDoc(props.value) - .setExtensions { - (if (props.readOnly) readOnlyExtensions else editableExtensions) ++ basicExtensions - } + private def init( + props: SimpleEditor, + ref: Ref.Simple[Element], + editorView: UseStateF[CallbackTo, EditorView] + ): Callback = ref.foreachCB(divRef => { + val basicExtensions = js.Array[Any]( + Editor.editorTheme.of(props.codemirrorTheme), + Editor.indentationMarkersExtension, + typings.codemirror.mod.minimalSetup, + mod.StreamLanguage.define(typings.codemirrorLegacyModes.modeClikeMod.scala_), + SyntaxHighlightingTheme.highlightingTheme + ) + lazy val readOnlyExtensions = js.Array[Any]( + EditorState.readOnly.of(true) + ) + lazy val editableExtensions = js.Array[Any]( + lineNumbers(), + OnChangeHandler(props.onChange) + ) + val editorStateConfig = EditorStateConfig() + .setDoc(props.value) + .setExtensions { + (if (props.readOnly) readOnlyExtensions else editableExtensions) ++ basicExtensions + } - val editor = new EditorView(EditorViewConfig() + val editor = new EditorView( + EditorViewConfig() .setState(EditorState.create(editorStateConfig)) .setParent(divRef) - ) + ) - editorView.setState(editor) - }) + editorView.setState(editor) + }) private def updateComponent( - props: SimpleEditor, - ref: Ref.Simple[Element], - prevProps: Option[SimpleEditor], - editorView: UseStateF[CallbackTo, EditorView] + props: SimpleEditor, + ref: Ref.Simple[Element], + prevProps: Option[SimpleEditor], + editorView: UseStateF[CallbackTo, EditorView] ): Callback = { Editor.updateCode(editorView, props) >> Editor.updateTheme(ref, prevProps, props, editorView) } - val hooksComponent = - ScalaFnComponent - .withHooks[SimpleEditor] - .useRef(Ref[Element]) - .useState(new EditorView()) - .useRef[Option[SimpleEditor]](None) - .useLayoutEffectOnMountBy((props, ref, editorView, prevProps) => init(props, ref.value, editorView)) - .useEffectBy((props, ref, editorRef, prevProps) => updateComponent(props, ref.value, prevProps.value, editorRef)) - .useEffectBy((props, _, editorRef, prevProps) => prevProps.set(Some(props))) - .render((props, ref, _, prevProps) => Editor.render(ref.value)) + val hooksComponent = ScalaFnComponent + .withHooks[SimpleEditor] + .useRef(Ref[Element]) + .useState(new EditorView()) + .useRef[Option[SimpleEditor]](None) + .useLayoutEffectOnMountBy((props, ref, editorView, prevProps) => init(props, ref.value, editorView)) + .useEffectBy((props, ref, editorRef, prevProps) => updateComponent(props, ref.value, prevProps.value, editorRef)) + .useEffectBy((props, _, editorRef, prevProps) => prevProps.set(Some(props))) + .render((props, ref, _, prevProps) => Editor.render(ref.value)) + } diff --git a/client/src/main/scala/org/scastie/client/components/editor/SyntaxHighlightingHandler.scala b/client/src/main/scala/org/scastie/client/components/editor/SyntaxHighlightingHandler.scala index aa767afef..c731a2405 100644 --- a/client/src/main/scala/org/scastie/client/components/editor/SyntaxHighlightingHandler.scala +++ b/client/src/main/scala/org/scastie/client/components/editor/SyntaxHighlightingHandler.scala @@ -1,15 +1,15 @@ package org.scastie.client.components.editor -import typings.codemirrorState.mod.ChangeSet -import typings.codemirrorState.mod._ -import typings.codemirrorView.mod._ -import typings.webTreeSitter.mod._ - import scala.collection.mutable.ListBuffer import scalajs.js +import typings.codemirrorState.mod._ +import typings.codemirrorState.mod.ChangeSet +import typings.codemirrorView.mod._ +import typings.webTreeSitter.mod._ -class SyntaxHighlightingHandler(parser: Parser, language: Language, query: Query, initialState: String) extends js.Object { +class SyntaxHighlightingHandler(parser: Parser, language: Language, query: Query, initialState: String) + extends js.Object { val queryCaptureNames = query.captureNames var tree = parser.parse(initialState) @@ -19,7 +19,7 @@ class SyntaxHighlightingHandler(parser: Parser, language: Language, query: Query val rangeSetBuilder = new RangeSetBuilder[Decoration]() val captures = query.captures(tree.rootNode) - captures.foldLeft(Option.empty[QueryCapture]){ (previousCapture, currentCapture) => + captures.foldLeft(Option.empty[QueryCapture]) { (previousCapture, currentCapture) => if (!previousCapture.exists(_ == currentCapture)) { val startPosition = currentCapture.node.startIndex val endPosition = currentCapture.node.endIndex @@ -27,7 +27,8 @@ class SyntaxHighlightingHandler(parser: Parser, language: Language, query: Query val mark = Decoration.mark( MarkDecorationSpec() .setInclusive(true) - .setClass(currentCapture.name.replace(".", "-"))) + .setClass(currentCapture.name.replace(".", "-")) + ) rangeSetBuilder.add(startPosition, endPosition, mark) } @@ -45,12 +46,14 @@ class SyntaxHighlightingHandler(parser: Parser, language: Language, query: Query private def mapChangesToTSEdits(changes: ChangeSet, originalText: Text, newText: Text): List[Edit] = { val editBuffer = new ListBuffer[Edit]() - changes.iterChanges { (fromA: Double, toA: Double, _, toB: Double, _) => { - val oldEndPosition = indexToTSPoint(originalText, toA) - val newEndPosition = indexToTSPoint(newText, toB) - val startPosition = indexToTSPoint(originalText, fromA) - editBuffer.addOne(Edit(toB, newEndPosition, toA, oldEndPosition, fromA, startPosition)) - }} + changes.iterChanges { (fromA: Double, toA: Double, _, toB: Double, _) => + { + val oldEndPosition = indexToTSPoint(originalText, toA) + val newEndPosition = indexToTSPoint(newText, toB) + val startPosition = indexToTSPoint(originalText, fromA) + editBuffer.addOne(Edit(toB, newEndPosition, toA, oldEndPosition, fromA, startPosition)) + } + } editBuffer.toList @@ -65,4 +68,5 @@ class SyntaxHighlightingHandler(parser: Parser, language: Language, query: Query decorations = computeDecorations() } } + } diff --git a/client/src/main/scala/org/scastie/client/components/editor/SyntaxHighlightingPlugin.scala b/client/src/main/scala/org/scastie/client/components/editor/SyntaxHighlightingPlugin.scala index ce1970e58..d93d54cdb 100644 --- a/client/src/main/scala/org/scastie/client/components/editor/SyntaxHighlightingPlugin.scala +++ b/client/src/main/scala/org/scastie/client/components/editor/SyntaxHighlightingPlugin.scala @@ -1,39 +1,39 @@ package org.scastie.client.components.editor -import typings.webTreeSitter.mod._ +import japgolly.scalajs.react._ import org.scalablytyped.runtime.StObject +import org.scalajs.dom import org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits._ -import japgolly.scalajs.react._ +import scalajs.js import typings.codemirrorState.mod._ import typings.codemirrorView.mod._ -import scalajs.js -import org.scalajs.dom +import typings.webTreeSitter.mod._ class SyntaxHighlightingPlugin(editorView: hooks.Hooks.UseStateF[CallbackTo, EditorView]) { val syntaxHighlightingExtension = new Compartment() - val fallbackExtension = typings.codemirrorLanguage.mod.StreamLanguage.define(typings.codemirrorLegacyModes.modeClikeMod.scala_).extension + val fallbackExtension = + typings.codemirrorLanguage.mod.StreamLanguage.define(typings.codemirrorLegacyModes.modeClikeMod.scala_).extension val location = dom.window.location + // this is workaround until we migrate all services to proper docker setup or unify the servers - val apiBase = if (location.hostname == "localhost") { - location.protocol ++ "//" ++ location.hostname + ":" ++ "9000" - } else if (location.protocol == "file:") { - "http://localhost:9000" - } else { - "https://scastie.scala-lang.org" - } + val apiBase = + if (location.hostname == "localhost") { + location.protocol ++ "//" ++ location.hostname + ":" ++ "9000" + } else if (location.protocol == "file:") { + "http://localhost:9000" + } else { + "https://scastie.scala-lang.org" + } val initOptions = new js.Object { val apiBaseField = apiBase - def locateFile(scriptName: String, scriptDirectory: String): String = - s"$apiBaseField/public/tree-sitter.wasm" + def locateFile(scriptName: String, scriptDirectory: String): String = s"$apiBaseField/public/tree-sitter.wasm" } - private val fetchTSWasm = init(initOptions) - .toFuture + private val fetchTSWasm = init(initOptions).toFuture .flatMap(_ => Language.load(s"$apiBase/public/tree-sitter-scala.wasm").toFuture) - val highlightQuery = dom.fetch(s"$apiBase/public/highlights.scm") for { @@ -48,16 +48,21 @@ class SyntaxHighlightingPlugin(editorView: hooks.Hooks.UseStateF[CallbackTo, Edi } def switchToTreesitterParser(scalaParser: Parser, language: Language, query: Query): Unit = { - val extension = ViewPlugin.define(editorView => - new SyntaxHighlightingHandler(scalaParser, language, query, editorView.state.doc.toString), - PluginSpec[SyntaxHighlightingHandler]().setDecorations(_.decorations) - ).extension + val extension = ViewPlugin + .define( + editorView => new SyntaxHighlightingHandler(scalaParser, language, query, editorView.state.doc.toString), + PluginSpec[SyntaxHighlightingHandler]().setDecorations(_.decorations) + ) + .extension val effects = syntaxHighlightingExtension.reconfigure(extension) val transactionSpec = TransactionSpec().setEffects(effects) - editorView.modState(editorView => { + editorView + .modState(editorView => { editorView.dispatch(transactionSpec) editorView - }).runNow() + }) + .runNow() } + } diff --git a/client/src/main/scala/org/scastie/client/components/editor/SyntaxHighlightingTheme.scala b/client/src/main/scala/org/scastie/client/components/editor/SyntaxHighlightingTheme.scala index 240503b50..662e6962d 100644 --- a/client/src/main/scala/org/scastie/client/components/editor/SyntaxHighlightingTheme.scala +++ b/client/src/main/scala/org/scastie/client/components/editor/SyntaxHighlightingTheme.scala @@ -1,10 +1,9 @@ package org.scastie.client.components.editor +import scalajs.js import typings.codemirrorLanguage.mod import typings.lezerHighlight.mod.tags -import scalajs.js - object SyntaxHighlightingTheme { private val highlightStyle = mod.HighlightStyle.define( @@ -63,7 +62,7 @@ object SyntaxHighlightingTheme { mod.TagStyle(tags.typeName).setClass("type"), mod.TagStyle(tags.typeOperator).setClass("type-qualifier"), mod.TagStyle(tags.unit).setClass("none"), - mod.TagStyle(tags.variableName).setClass("function"), + mod.TagStyle(tags.variableName).setClass("function") ) ) diff --git a/client/src/main/scala/org/scastie/client/components/editor/TreesitterParser.scala b/client/src/main/scala/org/scastie/client/components/editor/TreesitterParser.scala index 32bcdc6dc..ddd817119 100644 --- a/client/src/main/scala/org/scastie/client/components/editor/TreesitterParser.scala +++ b/client/src/main/scala/org/scastie/client/components/editor/TreesitterParser.scala @@ -1,13 +1,12 @@ package org.scastie.client.components.editor -import typings.webTreeSitter.mod.Parser +import scala.scalajs.js.annotation.JSGlobal + import org.scalablytyped.runtime.StObject import scalajs.js import scalajs.js.annotation.JSImport -import scala.scalajs.js.annotation.JSGlobal +import typings.webTreeSitter.mod.Parser @JSGlobal("Treesitter") @js.native -class TreesitterParser() extends StObject with Parser { - -} +class TreesitterParser() extends StObject with Parser {} diff --git a/client/src/main/scala/org/scastie/client/components/package.scala b/client/src/main/scala/org/scastie/client/components/package.scala index 35aea2cc5..7a9adc273 100644 --- a/client/src/main/scala/org/scastie/client/components/package.scala +++ b/client/src/main/scala/org/scastie/client/components/package.scala @@ -1,111 +1,78 @@ package org.scastie.client -import org.scastie.api._ -import org.scastie.runtime.api._ - +import japgolly.scalajs.react.Callback import japgolly.scalajs.react.Reusability import japgolly.scalajs.react.Reusable -import japgolly.scalajs.react.Callback import org.scalajs.dom.HTMLElement +import org.scastie.api._ +import org.scastie.runtime.api._ package object components { val reusableEmpty: Reusable[Callback] = Reusable.always(Callback.empty) - implicit val reusabilityBaseInputs: Reusability[BaseInputs] = - Reusability.byRefOr_== + implicit val reusabilityBaseInputs: Reusability[BaseInputs] = Reusability.byRefOr_== - implicit val reusabilityUser: Reusability[User] = - Reusability.byRef || Reusability.derive[User] + implicit val reusabilityUser: Reusability[User] = Reusability.byRef || Reusability.derive[User] - implicit val snippetIdReuse: Reusability[SnippetId] = - Reusability.byRefOr_== + implicit val snippetIdReuse: Reusability[SnippetId] = Reusability.byRefOr_== - implicit val viewReuse: Reusability[View] = - Reusability.byRefOr_== + implicit val viewReuse: Reusability[View] = Reusability.byRefOr_== - implicit val scalaTargetReuse: Reusability[ScalaTarget] = - Reusability.byRefOr_== + implicit val scalaTargetReuse: Reusability[ScalaTarget] = Reusability.byRefOr_== - implicit val sbtScalaTargetReuse: Reusability[SbtScalaTarget] = - Reusability.byRefOr_== + implicit val sbtScalaTargetReuse: Reusability[SbtScalaTarget] = Reusability.byRefOr_== - implicit val pageReuse: Reusability[Page] = - Reusability.byRefOr_== + implicit val pageReuse: Reusability[Page] = Reusability.byRefOr_== - implicit val scalaTargetTypeReuse: Reusability[ScalaTargetType] = - Reusability.byRefOr_== + implicit val scalaTargetTypeReuse: Reusability[ScalaTargetType] = Reusability.byRefOr_== - implicit val scalaScalaDependency: Reusability[ScalaDependency] = - Reusability.byRefOr_== + implicit val scalaScalaDependency: Reusability[ScalaDependency] = Reusability.byRefOr_== - implicit val attachedDomsReuse: Reusability[Map[String, HTMLElement]] = - Reusability.byRef || - Reusability.by(_.keys.toSet) + implicit val attachedDomsReuse: Reusability[Map[String, HTMLElement]] = Reusability.byRef || + Reusability.by(_.keys.toSet) - implicit val releaseOptionsReuse: Reusability[ReleaseOptions] = - Reusability.byRefOr_== + implicit val releaseOptionsReuse: Reusability[ReleaseOptions] = Reusability.byRefOr_== - implicit val projectReuse: Reusability[Project] = - Reusability.byRefOr_== + implicit val projectReuse: Reusability[Project] = Reusability.byRefOr_== - implicit val librariesFromReuse: Reusability[Map[ScalaDependency, Project]] = - Reusability.byRefOr_== + implicit val librariesFromReuse: Reusability[Map[ScalaDependency, Project]] = Reusability.byRefOr_== - implicit val instrumentationReuse: Reusability[Set[Instrumentation]] = - Reusability.byRefOr_== + implicit val instrumentationReuse: Reusability[Set[Instrumentation]] = Reusability.byRefOr_== - implicit val compilationInfosReuse: Reusability[Set[Problem]] = - Reusability.byRefOr_== + implicit val compilationInfosReuse: Reusability[Set[Problem]] = Reusability.byRefOr_== - implicit val runtimeErrorReuse: Reusability[Option[RuntimeError]] = - Reusability.byRefOr_== + implicit val runtimeErrorReuse: Reusability[Option[RuntimeError]] = Reusability.byRefOr_== - implicit val consoleOutputsReuse: Reusability[Vector[ConsoleOutput]] = - Reusability.byRefOr_== + implicit val consoleOutputsReuse: Reusability[Vector[ConsoleOutput]] = Reusability.byRefOr_== - implicit val snippetSummaryReuse: Reusability[List[SnippetSummary]] = - Reusability.byRefOr_== + implicit val snippetSummaryReuse: Reusability[List[SnippetSummary]] = Reusability.byRefOr_== - implicit val consoleStateReuse: Reusability[ConsoleState] = - Reusability.byRefOr_== + implicit val consoleStateReuse: Reusability[ConsoleState] = Reusability.byRefOr_== - implicit def reusabilityEventStream[T]: Reusability[EventStream[T]] = - Reusability.always + implicit def reusabilityEventStream[T]: Reusability[EventStream[T]] = Reusability.always - implicit val modalStateReuse: Reusability[ModalState] = - Reusability.derive[ModalState] + implicit val modalStateReuse: Reusability[ModalState] = Reusability.derive[ModalState] - implicit val snippetStateReuse: Reusability[SnippetState] = - Reusability.derive[SnippetState] + implicit val snippetStateReuse: Reusability[SnippetState] = Reusability.derive[SnippetState] - implicit val consoleOutputReuse: Reusability[ConsoleOutput] = - Reusability.byRefOr_== + implicit val consoleOutputReuse: Reusability[ConsoleOutput] = Reusability.byRefOr_== - implicit val outputsReuse: Reusability[Outputs] = - Reusability.derive[Outputs] + implicit val outputsReuse: Reusability[Outputs] = Reusability.derive[Outputs] - implicit val sbtRunnerStateReuse: Reusability[Option[Vector[SbtRunnerState]]] = - Reusability.byRefOr_== + implicit val sbtRunnerStateReuse: Reusability[Option[Vector[SbtRunnerState]]] = Reusability.byRefOr_== - implicit val statusStateReuse: Reusability[StatusState] = - Reusability.derive[StatusState] + implicit val statusStateReuse: Reusability[StatusState] = Reusability.derive[StatusState] - implicit val embeddedOptionsReuse: Reusability[EmbeddedOptions] = - Reusability.derive[EmbeddedOptions] + implicit val embeddedOptionsReuse: Reusability[EmbeddedOptions] = Reusability.derive[EmbeddedOptions] - implicit val metalsStatusReuse: Reusability[MetalsStatus] = - Reusability.byRefOr_== + implicit val metalsStatusReuse: Reusability[MetalsStatus] = Reusability.byRefOr_== - implicit val editorModeReuse: Reusability[EditorMode] = - Reusability.byRefOr_== + implicit val editorModeReuse: Reusability[EditorMode] = Reusability.byRefOr_== - implicit val reusabilityEditorModeToCallback: Reusability[EditorMode => Callback] = - Reusability.byRefOr_== + implicit val reusabilityEditorModeToCallback: Reusability[EditorMode => Callback] = Reusability.byRefOr_== - implicit val scastieStateReuse: Reusability[ScastieState] = - Reusability.derive[ScastieState] + implicit val scastieStateReuse: Reusability[ScastieState] = Reusability.derive[ScastieState] - implicit val scastieBackendReuse: Reusability[ScastieBackend] = - Reusability.byRefOr_== + implicit val scastieBackendReuse: Reusability[ScastieBackend] = Reusability.byRefOr_== } diff --git a/client/src/main/scala/org/scastie/client/i18n/I18n.scala b/client/src/main/scala/org/scastie/client/i18n/I18n.scala index 08d187b5c..4b08e1955 100644 --- a/client/src/main/scala/org/scastie/client/i18n/I18n.scala +++ b/client/src/main/scala/org/scastie/client/i18n/I18n.scala @@ -2,6 +2,7 @@ package org.scastie.client.i18n import scala.scalajs.js import scala.scalajs.js.annotation._ + import org.scalajs.dom import org.scalajs.dom.HTMLElement import org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits._ @@ -24,74 +25,72 @@ trait POItem extends js.Object { } object I18n { - private var translationsByLang = Map.empty[String, Map[String, String]] - private var currentLang: String = "en" + private var translationsByLang = Map.empty[String, Map[String, String]] + private var currentLang: String = "en" - private def parsePo(poContent: String): Map[String, String] = { - val msgidRegex = """^msgid\s+"(.*)"""".r - val msgstrRegex = """^msgstr\s+"(.*)"""".r - val quotedLine = """^"(.*)"""".r + private def parsePo(poContent: String): Map[String, String] = { + val msgidRegex = """^msgid\s+"(.*)"""".r + val msgstrRegex = """^msgstr\s+"(.*)"""".r + val quotedLine = """^"(.*)"""".r - var msgid: Option[String] = None - var msgstr: Option[String] = None - var collectingMsgid = false - var collectingMsgstr = false - var msgidBuffer = new StringBuilder - var msgstrBuffer = new StringBuilder - val translations = scala.collection.mutable.Map.empty[String, String] + var msgid: Option[String] = None + var msgstr: Option[String] = None + var collectingMsgid = false + var collectingMsgstr = false + var msgidBuffer = new StringBuilder + var msgstrBuffer = new StringBuilder + val translations = scala.collection.mutable.Map.empty[String, String] - for (line <- poContent.linesIterator) { - line.trim match { - case msgidRegex(first) => - collectingMsgid = true - collectingMsgstr = false - msgidBuffer.clear() - msgidBuffer.append(first) - case msgstrRegex(first) => - collectingMsgid = false - collectingMsgstr = true - msgstrBuffer.clear() - msgstrBuffer.append(first) - case quotedLine(text) if collectingMsgid => - msgidBuffer.append(text) - case quotedLine(text) if collectingMsgstr => - msgstrBuffer.append(text) - case l if l.isEmpty && msgidBuffer.nonEmpty && msgstrBuffer.nonEmpty => - translations += msgidBuffer.toString -> msgstrBuffer.toString - msgidBuffer.clear() - msgstrBuffer.clear() - collectingMsgid = false - collectingMsgstr = false - case _ => - } - } - if (msgidBuffer.nonEmpty && msgstrBuffer.nonEmpty) { - translations += msgidBuffer.toString -> msgstrBuffer.toString - } - translations.toMap + for (line <- poContent.linesIterator) { + line.trim match { + case msgidRegex(first) => + collectingMsgid = true + collectingMsgstr = false + msgidBuffer.clear() + msgidBuffer.append(first) + case msgstrRegex(first) => + collectingMsgid = false + collectingMsgstr = true + msgstrBuffer.clear() + msgstrBuffer.append(first) + case quotedLine(text) if collectingMsgid => msgidBuffer.append(text) + case quotedLine(text) if collectingMsgstr => msgstrBuffer.append(text) + case l if l.isEmpty && msgidBuffer.nonEmpty && msgstrBuffer.nonEmpty => + translations += msgidBuffer.toString -> msgstrBuffer.toString + msgidBuffer.clear() + msgstrBuffer.clear() + collectingMsgid = false + collectingMsgstr = false + case _ => + } } - - def loadPo(lang: String, poContent: String): Unit = { - val map = parsePo(poContent) - translationsByLang += lang -> map + if (msgidBuffer.nonEmpty && msgstrBuffer.nonEmpty) { + translations += msgidBuffer.toString -> msgstrBuffer.toString } + translations.toMap + } - def setLanguage(lang: String): Unit = { - Languages.available.get(lang) match { - case Some(poContent) => - if (!translationsByLang.contains(lang)) { - loadPo(lang, poContent) - } - currentLang = lang - case None => - currentLang = "en" + def loadPo(lang: String, poContent: String): Unit = { + val map = parsePo(poContent) + translationsByLang += lang -> map + } + + def setLanguage(lang: String): Unit = { + Languages.available.get(lang) match { + case Some(poContent) => + if (!translationsByLang.contains(lang)) { + loadPo(lang, poContent) } + currentLang = lang + case None => currentLang = "en" } + } - def getLanguage: String = currentLang + def getLanguage: String = currentLang - def t(msgid: String): String = { - val trans = translationsByLang.get(currentLang).flatMap(_.get(msgid)).getOrElse(msgid) - trans - } -} \ No newline at end of file + def t(msgid: String): String = { + val trans = translationsByLang.get(currentLang).flatMap(_.get(msgid)).getOrElse(msgid) + trans + } + +} diff --git a/client/src/main/scala/org/scastie/client/i18n/Languages.scala b/client/src/main/scala/org/scastie/client/i18n/Languages.scala index af2f8a2a2..245a63517 100644 --- a/client/src/main/scala/org/scastie/client/i18n/Languages.scala +++ b/client/src/main/scala/org/scastie/client/i18n/Languages.scala @@ -8,7 +8,9 @@ import scala.scalajs.js.annotation.JSImport object EnPo extends js.Any object Languages { + val available: Map[String, String] = Map( "en" -> (EnPo.asInstanceOf[String]) ) -} \ No newline at end of file + +} diff --git a/client/src/main/scala/org/scastie/client/package.scala b/client/src/main/scala/org/scastie/client/package.scala index 66a97d0d8..234a3762c 100644 --- a/client/src/main/scala/org/scastie/client/package.scala +++ b/client/src/main/scala/org/scastie/client/package.scala @@ -1,8 +1,6 @@ package org.scastie - import io.circe._ - import org.scalajs.dom.window package object client { @@ -17,8 +15,7 @@ package object client { def apply(a: T): Json = io.circe.Json.Null } - def dontSerializeList[T]: Codec[List[T]] = - dontSerialize(List()) + def dontSerializeList[T]: Codec[List[T]] = dontSerialize(List()) val isMac: Boolean = window.navigator.userAgent.contains("Mac") val isMobile: Boolean = "Android|webOS|Mobi|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Samsung".r.unanchored diff --git a/client/src/main/scala/org/scastie/client/scalacli/ScalaCliUtils.scala b/client/src/main/scala/org/scastie/client/scalacli/ScalaCliUtils.scala index ef083eb2c..c9abbb0c8 100644 --- a/client/src/main/scala/org/scastie/client/scalacli/ScalaCliUtils.scala +++ b/client/src/main/scala/org/scastie/client/scalacli/ScalaCliUtils.scala @@ -1,12 +1,12 @@ package org.scastie.client.scalacli -import org.scastie.api._ -import org.scastie.buildinfo.BuildInfo -import org.scastie.client.scalacli.ScalaVersionUtil._ - import scala.concurrent.Future import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue + import japgolly.scalajs.react.callback.Callback +import org.scastie.api._ +import org.scastie.buildinfo.BuildInfo +import org.scastie.client.scalacli.ScalaVersionUtil._ object ScalaCliUtils { @@ -15,14 +15,14 @@ object ScalaCliUtils { private val ToolkitRegex = """//> *using +toolkit +([^\s]+)""".r def parse(codeHeader: List[String]): Future[(ScalaTarget, Set[ScalaDependency])] = { - val maybeVersion: Option[Future[String]] = codeHeader.collectFirst { - case ScalaVersionRegex(v) => ScalaVersionUtil.resolveVersion(v) + val maybeVersion: Option[Future[String]] = codeHeader.collectFirst { case ScalaVersionRegex(v) => + ScalaVersionUtil.resolveVersion(v) } - val dependencies = codeHeader.collect { - case DepRegex(_, dep) => dep + val dependencies = codeHeader.collect { case DepRegex(_, dep) => + dep }.toSet - val maybeToolkitVersion = codeHeader.collectFirst { - case ToolkitRegex(v) => if (v == "latest") "latest.stable" else v + val maybeToolkitVersion = codeHeader.collectFirst { case ToolkitRegex(v) => + if (v == "latest") "latest.stable" else v } val versionFut = maybeVersion.getOrElse(Future.successful("")) @@ -32,27 +32,29 @@ object ScalaCliUtils { val toolkitDependency = maybeToolkitVersion.map(ScalaDependency("org.scala-lang", "toolkit", scalaTarget, _)) val deps = dependencies.flatMap { dep => dep.split(":").toList match { - case groupId :: "" :: artifactId :: version :: Nil => - Some(ScalaDependency(groupId, artifactId, scalaTarget, version)) - case groupId :: "" :: artifactId :: "" :: version :: Nil => - Some(ScalaDependency(groupId, artifactId, scalaTarget, version)) - case groupId :: artifactId :: version :: Nil => - Some(ScalaDependency(groupId, artifactId, scalaTarget, version, isAutoResolve = false)) - case _ => None - } - }.toSet + case groupId :: "" :: artifactId :: version :: Nil => + Some(ScalaDependency(groupId, artifactId, scalaTarget, version)) + case groupId :: "" :: artifactId :: "" :: version :: Nil => + Some(ScalaDependency(groupId, artifactId, scalaTarget, version)) + case groupId :: artifactId :: version :: Nil => + Some(ScalaDependency(groupId, artifactId, scalaTarget, version, isAutoResolve = false)) + case _ => None + } + }.toSet (scalaTarget, deps ++ toolkitDependency) } } implicit class InputConverter(inputs: BaseInputs) { + def setTarget(newTarget: ScalaTarget): BaseInputs = { inputs -> newTarget match { - case (sbtInputs: SbtInputs, newSbtScalaTarget: SbtScalaTarget) => sbtInputs.copy(target = newSbtScalaTarget) - case (scalaCliInputs: ScalaCliInputs, newScalaCliTarget: ScalaCli) => scalaCliInputs.copy(target = newScalaCliTarget) + case (sbtInputs: SbtInputs, newSbtScalaTarget: SbtScalaTarget) => sbtInputs.copy(target = newSbtScalaTarget) + case (scalaCliInputs: ScalaCliInputs, newScalaCliTarget: ScalaCli) => + scalaCliInputs.copy(target = newScalaCliTarget) case (_: ScalaCliInputs, newSbtScalaTarget: SbtScalaTarget) => convertToSbt(newSbtScalaTarget) - case (_: SbtInputs, _: ScalaCli) => convertToScalaCli - case _ => inputs + case (_: SbtInputs, _: ScalaCli) => convertToScalaCli + case _ => inputs } } @@ -90,5 +92,7 @@ object ScalaCliUtils { forked = None ) } + } + } diff --git a/client/src/main/scala/org/scastie/client/scalacli/ScalaVersionUtil.scala b/client/src/main/scala/org/scastie/client/scalacli/ScalaVersionUtil.scala index 7f8bfa2b0..dadb7ac3c 100644 --- a/client/src/main/scala/org/scastie/client/scalacli/ScalaVersionUtil.scala +++ b/client/src/main/scala/org/scastie/client/scalacli/ScalaVersionUtil.scala @@ -1,85 +1,87 @@ package org.scastie.client.scalacli import scala.concurrent.Future +import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue import scala.scalajs.js -import org.scalajs.dom import scala.util.matching.Regex -import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue + +import org.scalajs.dom object ScalaVersionUtil { - val location = dom.window.location - val apiBase = if (location.hostname == "localhost") { - location.protocol ++ "//" ++ location.hostname + ":" ++ "9000" + val location = dom.window.location + + val apiBase = + if (location.hostname == "localhost") { + location.protocol ++ "//" ++ location.hostname + ":" ++ "9000" } else if (location.protocol == "file:") { - "http://localhost:9000" + "http://localhost:9000" } else { - "https://scastie.scala-lang.org" + "https://scastie.scala-lang.org" } - val scala212Nightly = "2.12.nightly" - val scala213Nightly = List("2.13.nightly", "2.nightly") - val scala3Nightly = "3.nightly" + val scala212Nightly = "2.12.nightly" + val scala213Nightly = List("2.13.nightly", "2.nightly") + val scala3Nightly = "3.nightly" - private val ttlMillis: Long = 60 * 60 * 1000 // 1 hour + private val ttlMillis: Long = 60 * 60 * 1000 // 1 hour - private val nightlyRegex: Regex = - raw"(.+-bin-\d{8}-\w{7}-NIGHTLY)".r + private val nightlyRegex: Regex = raw"(.+-bin-\d{8}-\w{7}-NIGHTLY)".r - sealed trait ScalaNightly { - def fetchLatest(prefix: String): Future[Option[String]] - var cache : Option[(String, Long)] - } + sealed trait ScalaNightly { + def fetchLatest(prefix: String): Future[Option[String]] + var cache: Option[(String, Long)] + } - object Scala3Nightly extends ScalaNightly { - private val apiUrl = s"$apiBase/api/nightly-raw/scala3" - var cache: Option[(String, Long)] = None - def fetchLatest(prefix: String): Future[Option[String]] = { - dom.fetch(apiUrl) - .toFuture - .flatMap(_.text().toFuture) - .map { str => - val trimmed = str.trim - if (trimmed.nonEmpty) Some(trimmed) else None - } + object Scala3Nightly extends ScalaNightly { + private val apiUrl = s"$apiBase/api/nightly-raw/scala3" + var cache: Option[(String, Long)] = None + + def fetchLatest(prefix: String): Future[Option[String]] = { + dom + .fetch(apiUrl) + .toFuture + .flatMap(_.text().toFuture) + .map { str => + val trimmed = str.trim + if (trimmed.nonEmpty) Some(trimmed) else None } } - object Scala2Nightly extends ScalaNightly { - private val apiUrl = s"$apiBase/api/nightly-raw/scala2" - var cache: Option[(String, Long)] = None - def fetchLatest(prefix: String): Future[Option[String]] = { - dom.fetch(s"$apiUrl/$prefix") - .toFuture - .flatMap(_.text().toFuture) - .map { str => - val trimmed = str.trim - if (trimmed.nonEmpty) Some(trimmed) else None - } + } + + object Scala2Nightly extends ScalaNightly { + private val apiUrl = s"$apiBase/api/nightly-raw/scala2" + var cache: Option[(String, Long)] = None + + def fetchLatest(prefix: String): Future[Option[String]] = { + dom + .fetch(s"$apiUrl/$prefix") + .toFuture + .flatMap(_.text().toFuture) + .map { str => + val trimmed = str.trim + if (trimmed.nonEmpty) Some(trimmed) else None } } - private def resolveNightly(scalaVersion: ScalaNightly, prefix: String): Future[Option[String]] = { - val now = System.currentTimeMillis() - scalaVersion.cache match { - case Some((version, timestamp)) if now - timestamp < ttlMillis => - Future.successful(Some(version)) - case _ => - scalaVersion.fetchLatest(prefix).map { optVersion => - optVersion.foreach { v => scalaVersion.cache = Some((v, now)) } - optVersion - } + } + + private def resolveNightly(scalaVersion: ScalaNightly, prefix: String): Future[Option[String]] = { + val now = System.currentTimeMillis() + scalaVersion.cache match { + case Some((version, timestamp)) if now - timestamp < ttlMillis => Future.successful(Some(version)) + case _ => scalaVersion.fetchLatest(prefix).map { optVersion => + optVersion.foreach { v => scalaVersion.cache = Some((v, now)) } + optVersion } } + } + + def resolveVersion(version: String): Future[String] = version match { + case v if v == scala212Nightly => resolveNightly(Scala2Nightly, "2.12").map(_.getOrElse(v)) + case v if scala213Nightly.contains(v) => resolveNightly(Scala2Nightly, "2.13").map(_.getOrElse(v)) + case v if v == scala3Nightly => resolveNightly(Scala3Nightly, "3").map(_.getOrElse(v)) + case _ => Future.successful(version) + } - def resolveVersion(version: String): Future[String] = - version match { - case v if v == scala212Nightly => - resolveNightly(Scala2Nightly, "2.12").map(_.getOrElse(v)) - case v if scala213Nightly.contains(v) => - resolveNightly(Scala2Nightly, "2.13").map(_.getOrElse(v)) - case v if v == scala3Nightly => - resolveNightly(Scala3Nightly, "3").map(_.getOrElse(v)) - case _ => - Future.successful(version) - } } diff --git a/instrumentation/src/main/scala/org/scastie/instrumentation/Instrument.scala b/instrumentation/src/main/scala/org/scastie/instrumentation/Instrument.scala index 97a858018..124662fe4 100644 --- a/instrumentation/src/main/scala/org/scastie/instrumentation/Instrument.scala +++ b/instrumentation/src/main/scala/org/scastie/instrumentation/Instrument.scala @@ -1,17 +1,17 @@ package org.scastie.instrumentation -import org.scastie.api._ -import org.scastie.runtime.api._ -import RuntimeConstants._ - import scala.collection.immutable.Seq import scala.meta._ import scala.meta.inputs.Position import scala.meta.parsers.Parsed import scala.util.control.NonFatal -import org.scastie.buildinfo.BuildInfo -import org.scastie.api.ScalaTargetType.Scala2 + +import org.scastie.api._ import org.scastie.api.ScalaTargetType.JS +import org.scastie.api.ScalaTargetType.Scala2 +import org.scastie.buildinfo.BuildInfo +import org.scastie.runtime.api._ +import RuntimeConstants._ sealed trait InstrumentationFailure @@ -30,18 +30,18 @@ case class InstrumentationSuccess( object Instrument { def getParsingLineOffset(isWorksheet: Boolean): Int = if (isWorksheet) -1 else 0 def getExceptionLineOffset(isWorksheet: Boolean): Int = if (isWorksheet) -2 else 0 + def getMessageLineOffset(isWorksheet: Boolean, isScalaCli: Boolean): Int = (isWorksheet, isScalaCli) match { - case (true, _) => -2 - case (false, true) => 1 - case (false, false) => 0 + case (true, _) => -2 + case (false, true) => 1 + case (false, false) => 0 } import InstrumentationFailure._ val entryPointName = "Main" - private val elemArrayT = - "_root_.scala.scalajs.js.Array[_root_.org.scalajs.dom.raw.HTMLElement]" + private val elemArrayT = "_root_.scala.scalajs.js.Array[_root_.org.scalajs.dom.raw.HTMLElement]" private def extractExperimentalImports(code: String): (String, String) = { val experimentalRegex = """^\s*import\s+language\.experimental\.[^\n]+""".r @@ -50,41 +50,38 @@ object Instrument { val codeWithoutExpImports = experimentalRegex.replaceAllIn(code, m => "/*" + " " * (m.matched.length - 4) + "*/") val experimental = experimentalImports.mkString("\n") + (if (experimentalImports.nonEmpty) "\n" else "") - + (experimental, codeWithoutExpImports) } private def posToApi(position: Position, offset: Int) = { val (x, y) = position match { - case Position.None => (0, 0) - case Position.Range(_, start, end) => - (start - offset, end - offset) + case Position.None => (0, 0) + case Position.Range(_, start, end) => (start - offset, end - offset) } s"$positionT($x, $y)" } def instrumentOne(term: Term, tpeTree: Option[Type], offset: Int, isScalaJs: Boolean): Patch = { - val treeQuote = - tpeTree match { - case None => s"val $$t = $term" - case Some(tpe) => s"val $$t: $tpe = $term" - } + val treeQuote = tpeTree match { + case None => s"val $$t = $term" + case Some(tpe) => s"val $$t: $tpe = $term" + } val startPos = term.pos.start - offset - val endPos = term.pos.end - offset + val endPos = term.pos.end - offset val renderCall = if (!isScalaJs) s"$runtimeT.render($$t)" else s"$runtimeT.render($$t, attach _)" - val replacement = - s"""|scala.Predef.locally { - |$$doc.startStatement($startPos, $endPos); - |$treeQuote; - |$$doc.binder($renderCall, $startPos, $endPos); - |$$doc.endStatement(); - |$$t}""".stripMargin + val replacement = s"""|scala.Predef.locally { + |$$doc.startStatement($startPos, $endPos); + |$treeQuote; + |$$doc.binder($renderCall, $startPos, $endPos); + |$$doc.endStatement(); + |$$t}""".stripMargin Patch(term.tokens.head, term.tokens.last, replacement) } @@ -94,9 +91,8 @@ object Instrument { case c: Defn.Object if c.name.value == instrumentedObject => c.templ.body.stats .collect { - case term: Term if !term.isInstanceOf[Term.EndMarker] => - instrumentOne(term, None, offset, isScalaJs) - } + case term: Term if !term.isInstanceOf[Term.EndMarker] => instrumentOne(term, None, offset, isScalaJs) + } }.flatten val instrumentedCode = Patch(source.tokens, instrumentedCodePatches) @@ -141,8 +137,7 @@ object Instrument { } } val apps = Set("App", "IOApp") - def hasApp(templ: Template): Boolean = - templ.inits.exists(p => apps(p.syntax)) + def hasApp(templ: Template): Boolean = templ.inits.exists(p => apps(p.syntax)) source.stats.exists { case c: Defn.Object if c.name.value == instrumentedObject => @@ -156,7 +151,11 @@ object Instrument { } } - def separateDirectives(code: String, targetType: ScalaTargetType, additionalDirectives: Seq[String]): (String, String) = { + def separateDirectives( + code: String, + targetType: ScalaTargetType, + additionalDirectives: Seq[String] + ): (String, String) = { if (targetType == ScalaTargetType.ScalaCli) { val directiveRegex = """^//>.*(?:\n|$)""".r val lines = code.linesWithSeparators.toList @@ -178,7 +177,7 @@ object Instrument { def apply(code: String, target: ScalaTarget): Either[InstrumentationFailure, InstrumentationSuccess] = { val runtimeImport = target match { case Scala3(scalaVersion) => s"import $runtimePackage.*" - case _ => s"import $runtimePackage._" + case _ => s"import $runtimePackage._" } val isScalaJs = target.targetType == ScalaTargetType.JS @@ -215,7 +214,7 @@ object Instrument { val offset = target match { case _: ScalaCli => usingDirectives.length + prelude.length + 1 - case _ => prelude.length + 1 + case _ => prelude.length + 1 } maybeDialect match { @@ -230,20 +229,22 @@ object Instrument { val lineMapping = LineMapper(instrumentedCode) - Right(InstrumentationSuccess( - s"""$instrumentedCode\n$entryPoint""", - lineMapping - )) + Right( + InstrumentationSuccess( + s"""$instrumentedCode\n$entryPoint""", + lineMapping + ) + ) } else { Left(HasMainMethod) } case e: Parsed.Error => Left(ParsingError(e)) } } catch { - case NonFatal(e) => - Left(InternalError(e)) + case NonFatal(e) => Left(InternalError(e)) } case None => Left(UnsupportedDialect) } } + } diff --git a/instrumentation/src/main/scala/org/scastie/instrumentation/InstrumentedInputs.scala b/instrumentation/src/main/scala/org/scastie/instrumentation/InstrumentedInputs.scala index f02a0c4a7..297d1c388 100644 --- a/instrumentation/src/main/scala/org/scastie/instrumentation/InstrumentedInputs.scala +++ b/instrumentation/src/main/scala/org/scastie/instrumentation/InstrumentedInputs.scala @@ -2,13 +2,13 @@ package org.scastie.instrumentation import java.io.{PrintWriter, StringWriter} import java.time.Instant - -import org.scastie.api._ - import scala.meta.inputs.Input import scala.meta.parsers.Parsed +import org.scastie.api._ + case class InstrumentationFailureReport(message: String, line: Option[Int]) { + def toProgress(snippetId: SnippetId): SnippetProgress = { SnippetProgress.default.copy( ts = Some(Instant.now.toEpochMilli), @@ -16,9 +16,11 @@ case class InstrumentationFailureReport(message: String, line: Option[Int]) { compilationInfos = List(Problem(Error, line, message)) ) } + } object InstrumentedInputs { + def apply(inputs0: BaseInputs): Either[InstrumentationFailureReport, InstrumentedInputs] = { if (inputs0.isWorksheetMode) { val instrumented = Instrument(inputs0.code, inputs0.target).map { @@ -47,11 +49,13 @@ object InstrumentedInputs { case ParsingError(error) => val lineOffset = Instrument.getParsingLineOffset(inputs0.isWorksheetMode) val errorLine = (error.pos.startLine + lineOffset) max 1 - Right(InstrumentedInputs( - inputs = inputs0.copyBaseInput(code = error.pos.input.text), - isForcedProgramMode = false, - optionalParsingError = Some(InstrumentationFailureReport(error.message, Some(errorLine))), - )) + Right( + InstrumentedInputs( + inputs = inputs0.copyBaseInput(code = error.pos.input.text), + isForcedProgramMode = false, + optionalParsingError = Some(InstrumentationFailureReport(error.message, Some(errorLine))) + ) + ) case InternalError(exception) => val errors = new StringWriter() diff --git a/instrumentation/src/main/scala/org/scastie/instrumentation/LineMapper.scala b/instrumentation/src/main/scala/org/scastie/instrumentation/LineMapper.scala index 35e52d53e..90722b519 100644 --- a/instrumentation/src/main/scala/org/scastie/instrumentation/LineMapper.scala +++ b/instrumentation/src/main/scala/org/scastie/instrumentation/LineMapper.scala @@ -17,7 +17,7 @@ object LineMapper { } private def buildSequentialMapping(instrumentedCode: String): Int => Int = { - val lines = instrumentedCode.split('\n') + val lines = instrumentedCode.split('\n') val mappings = calculateSequentialMappings(lines) instrumentedLineNumber => mappings.getOrElse(instrumentedLineNumber, instrumentedLineNumber) @@ -30,7 +30,7 @@ object LineMapper { lines.zipWithIndex .foldLeft(State()) { case (State(userCodeLinesSeen, mappings), (line, index)) => val instrumentedLineNumber = index + 1 - val trimmed = line.trim + val trimmed = line.trim if (!isExperimentalImport(trimmed) && !isInstrumentationLine(trimmed)) { val newCount = userCodeLinesSeen + 1 diff --git a/instrumentation/src/main/scala/org/scastie/instrumentation/Patch.scala b/instrumentation/src/main/scala/org/scastie/instrumentation/Patch.scala index 65bff59fe..73a7e18d0 100644 --- a/instrumentation/src/main/scala/org/scastie/instrumentation/Patch.scala +++ b/instrumentation/src/main/scala/org/scastie/instrumentation/Patch.scala @@ -5,12 +5,13 @@ import scala.meta._ import scala.meta.tokens.Token case class Patch(from: Token, to: Token, replace: String) { - def insideRange(token: Token): Boolean = - (token.input eq from.input) && - token.end <= to.end && - token.start >= from.start + + def insideRange(token: Token): Boolean = (token.input eq from.input) && + token.end <= to.end && + token.start >= from.start val tokens: scala.Seq[Token] = replace.tokenize.get.tokens.toSeq + def runOn(str: Seq[Token]): Seq[Token] = { str.flatMap { case `from` => tokens @@ -18,20 +19,24 @@ case class Patch(from: Token, to: Token, replace: String) { case x => Seq(x) } } + } object Patch { + def verifyPatches(patches: Seq[Patch]): Unit = { // TODO(olafur) assert there's no conflicts. } + def apply(input: Seq[Token], patches: Seq[Patch]): String = { verifyPatches(patches) // TODO(olafur) optimize, this is SUPER inefficient patches - .foldLeft(input) { - case (s, p) => p.runOn(s) + .foldLeft(input) { case (s, p) => + p.runOn(s) } .map(_.syntax) .mkString("") } + } diff --git a/instrumentation/src/main/scala/org/scastie/instrumentation/RuntimeConstants.scala b/instrumentation/src/main/scala/org/scastie/instrumentation/RuntimeConstants.scala index 1bb88a30b..0623b16a2 100644 --- a/instrumentation/src/main/scala/org/scastie/instrumentation/RuntimeConstants.scala +++ b/instrumentation/src/main/scala/org/scastie/instrumentation/RuntimeConstants.scala @@ -3,20 +3,20 @@ package org.scastie.instrumentation import org.scastie.runtime.api._ object RuntimeConstants { - val instrumentedObject = Instrumentation.instrumentedObject + val instrumentedObject = Instrumentation.instrumentedObject val instrumentationMethod = "instrumentations$" - val emptyMapT = "_root_.scala.collection.mutable.Map.empty" - val jsExportT = "_root_.scala.scalajs.js.annotation.JSExport" + val emptyMapT = "_root_.scala.collection.mutable.Map.empty" + val jsExportT = "_root_.scala.scalajs.js.annotation.JSExport" val jsExportTopLevelT = "_root_.scala.scalajs.js.annotation.JSExportTopLevel" - val runtimePackage = "_root_.org.scastie.runtime" - val runtimeApiPackage = "_root_.org.scastie.runtime.api" - val positionT = s"$runtimeApiPackage.Position" - val renderT = s"$runtimeApiPackage.Render" - val runtimeErrorT = s"$runtimeApiPackage.RuntimeError" - val instrumentationT = s"$runtimeApiPackage.Instrumentation" - val runtimeT = s"$runtimePackage.Runtime" - val domhookT = s"$runtimePackage.DomHook" + val runtimePackage = "_root_.org.scastie.runtime" + val runtimeApiPackage = "_root_.org.scastie.runtime.api" + val positionT = s"$runtimeApiPackage.Position" + val renderT = s"$runtimeApiPackage.Render" + val runtimeErrorT = s"$runtimeApiPackage.RuntimeError" + val instrumentationT = s"$runtimeApiPackage.Instrumentation" + val runtimeT = s"$runtimePackage.Runtime" + val domhookT = s"$runtimePackage.DomHook" val instrumentationRecorderT = s"$runtimePackage.InstrumentationRecorder" } diff --git a/instrumentation/src/test/scala/org/scastie/instrumentation/Diff.scala b/instrumentation/src/test/scala/org/scastie/instrumentation/Diff.scala index 612161ee6..509b4ac02 100644 --- a/instrumentation/src/test/scala/org/scastie/instrumentation/Diff.scala +++ b/instrumentation/src/test/scala/org/scastie/instrumentation/Diff.scala @@ -3,9 +3,10 @@ package org.scastie.instrumentation import org.scastie.util.ScastieFileUtil case class DiffFailure(title: String, expected: String, obtained: String, diff: String) - extends Exception(title + "\n" + Diff.error2message(obtained, expected)) + extends Exception(title + "\n" + Diff.error2message(obtained, expected)) object Diff { + def error2message(obtained: String, expected: String): String = { ScastieFileUtil.write(new java.io.File("target/obtained.scala").toPath, obtained, truncate = true) val sb = new StringBuilder @@ -13,18 +14,18 @@ object Diff { sb.append("\n") sb.append(s""" - ## Obtained - #${trailingSpace(obtained)} + ## Obtained + #${trailingSpace(obtained)} """.stripMargin('#')) sb.append(s""" - ## Expected - #${trailingSpace(expected)} + ## Expected + #${trailingSpace(expected)} """.stripMargin('#')) sb.append(s""" - ## Diff - #${trailingSpace(compareContents(obtained, expected))} + ## Diff + #${trailingSpace(compareContents(obtained, expected))} """.stripMargin('#')) sb.toString() } @@ -41,7 +42,7 @@ object Diff { def compareContents(obtained: String, expected: String): String = { compareContents( expected = expected.replace("\r\n", "\n").trim.split("\n").toList, - obtained = obtained.replace("\r\n", "\n").trim.split("\n").toList, + obtained = obtained.replace("\r\n", "\n").trim.split("\n").toList ) } @@ -49,16 +50,16 @@ object Diff { import scala.jdk.CollectionConverters._ val diff = difflib.DiffUtils.diff(expected.asJava, obtained.asJava) if (diff.getDeltas.isEmpty) "" - else - difflib.DiffUtils - .generateUnifiedDiff( - "expected", - "obtained", - expected.asJava, - diff, - 1 - ) - .asScala - .mkString("\n") + else difflib.DiffUtils + .generateUnifiedDiff( + "expected", + "obtained", + expected.asJava, + diff, + 1 + ) + .asScala + .mkString("\n") } + } diff --git a/instrumentation/src/test/scala/org/scastie/instrumentation/InstrumentSpecs.scala b/instrumentation/src/test/scala/org/scastie/instrumentation/InstrumentSpecs.scala index e990cc668..543a7975b 100644 --- a/instrumentation/src/test/scala/org/scastie/instrumentation/InstrumentSpecs.scala +++ b/instrumentation/src/test/scala/org/scastie/instrumentation/InstrumentSpecs.scala @@ -1,13 +1,13 @@ package org.scastie package instrumentation -import org.scastie.api._ -import org.scastie.util.ScastieFileUtil.slurp -import org.scalatest.funsuite.AnyFunSuite - import java.nio.file._ import scala.jdk.CollectionConverters._ +import org.scalatest.funsuite.AnyFunSuite +import org.scastie.api._ +import org.scastie.util.ScastieFileUtil.slurp + class InstrumentSpecs extends AnyFunSuite { import InstrumentationFailure._ @@ -32,9 +32,8 @@ class InstrumentSpecs extends AnyFunSuite { else if (dirName == "scala3") Scala3.default else Scala2.default - val Right(obtained) = Instrument(original, target).map { - case InstrumentationSuccess(instrumentedCode, _) => - instrumentedCode + val Right(obtained) = Instrument(original, target).map { case InstrumentationSuccess(instrumentedCode, _) => + instrumentedCode } Files.write(dir.resolve("obtained.scala"), obtained.getBytes(java.nio.charset.StandardCharsets.UTF_8)) @@ -48,18 +47,15 @@ class InstrumentSpecs extends AnyFunSuite { } test("main method fails") { - val Left(HasMainMethod) = - Instrument("object Main { def main(args: Array[String]): Unit = () }", Scala2.default) + val Left(HasMainMethod) = Instrument("object Main { def main(args: Array[String]): Unit = () }", Scala2.default) } test("extends App trait fails") { - val Left(HasMainMethod) = - Instrument("object Main extends App { }", Scala2.default) + val Left(HasMainMethod) = Instrument("object Main extends App { }", Scala2.default) } test("with App trait fails") { - val Left(HasMainMethod) = - Instrument("trait Foo; object Main extends Foo with App { }", Scala2.default) + val Left(HasMainMethod) = Instrument("trait Foo; object Main extends Foo with App { }", Scala2.default) } test("extends App primary fails") { diff --git a/instrumentation/src/test/scala/org/scastie/instrumentation/LineMapperSpecs.scala b/instrumentation/src/test/scala/org/scastie/instrumentation/LineMapperSpecs.scala index a1ee4ab7c..f3decaef3 100644 --- a/instrumentation/src/test/scala/org/scastie/instrumentation/LineMapperSpecs.scala +++ b/instrumentation/src/test/scala/org/scastie/instrumentation/LineMapperSpecs.scala @@ -48,8 +48,8 @@ class LineMapperSpecs extends AnyFunSuite { val lineMapping = LineMapper(code1) - assert(lineMapping(5) == 1) // val $t = println("test1"); - assert(lineMapping(9) == 2) // val y = 1 + assert(lineMapping(5) == 1) // val $t = println("test1"); + assert(lineMapping(9) == 2) // val y = 1 assert(lineMapping(12) == 3) // val $t = println("test2"); } @@ -59,25 +59,24 @@ class LineMapperSpecs extends AnyFunSuite { |val x = 1 |""".stripMargin - val code1 = - s"""|import $runtimePackage.* - |import language.experimental.captureChecking - |object $instrumentedObject extends ScastieApp with $instrumentationRecorderT { - | - |scala.Predef.locally { - |$$doc.startStatement(0, 15); - |val $$t = println("test"); - |$$doc.binder($runtimeT.render($$t), 0, 15); - |$$doc.endStatement(); - |$$t} - |val x = 1 - |} - |""".stripMargin + val code1 = s"""|import $runtimePackage.* + |import language.experimental.captureChecking + |object $instrumentedObject extends ScastieApp with $instrumentationRecorderT { + | + |scala.Predef.locally { + |$$doc.startStatement(0, 15); + |val $$t = println("test"); + |$$doc.binder($runtimeT.render($$t), 0, 15); + |$$doc.endStatement(); + |$$t} + |val x = 1 + |} + |""".stripMargin val lineMapping = LineMapper(code1) - assert(lineMapping(2) == 1) // experimental import - assert(lineMapping(7) == 2) // val $t = println("test"); + assert(lineMapping(2) == 1) // experimental import + assert(lineMapping(7) == 2) // val $t = println("test"); assert(lineMapping(11) == 3) // val x = 1 } @@ -102,8 +101,8 @@ class LineMapperSpecs extends AnyFunSuite { val lineMapping = LineMapper(code1) - assert(lineMapping(5) == 1) // val $t = println: - assert(lineMapping(6) == 2) // "multiline"; + assert(lineMapping(5) == 1) // val $t = println: + assert(lineMapping(6) == 2) // "multiline"; assert(lineMapping(10) == 3) // val x = 1 } @@ -132,9 +131,9 @@ class LineMapperSpecs extends AnyFunSuite { val lineMapping = LineMapper(code1) - assert(lineMapping(3) == 1) // Comment 1 - assert(lineMapping(4) == 2) // empty line - assert(lineMapping(7) == 3) // val $t = println("test"); + assert(lineMapping(3) == 1) // Comment 1 + assert(lineMapping(4) == 2) // empty line + assert(lineMapping(7) == 3) // val $t = println("test"); assert(lineMapping(11) == 4) // Comment 2 assert(lineMapping(12) == 5) // val x = 1 } @@ -250,11 +249,11 @@ class LineMapperSpecs extends AnyFunSuite { val lineMapping = LineMapper(code1) - assert(lineMapping(4) == 1) // import scala.concurrent.Future - assert(lineMapping(7) == 4) // Setup comment - assert(lineMapping(8) == 5) // val data = List(1, 2, 3) - assert(lineMapping(10) == 7) // Processing comment - assert(lineMapping(13) == 8) // val $t = data.foreach... + assert(lineMapping(4) == 1) // import scala.concurrent.Future + assert(lineMapping(7) == 4) // Setup comment + assert(lineMapping(8) == 5) // val data = List(1, 2, 3) + assert(lineMapping(10) == 7) // Processing comment + assert(lineMapping(13) == 8) // val $t = data.foreach... assert(lineMapping(20) == 12) // val result = data.map(_ * 2) assert(lineMapping(23) == 13) // val $t = println(result); } @@ -287,9 +286,9 @@ class LineMapperSpecs extends AnyFunSuite { val lineMapping = LineMapper(code1) - assert(lineMapping(5) == 1) // val $t = println("test"); #1 + assert(lineMapping(5) == 1) // val $t = println("test"); #1 assert(lineMapping(12) == 3) // val $t = println("test"); #2 - assert(lineMapping(9) == 2) // val y = 2 + assert(lineMapping(9) == 2) // val y = 2 assert(lineMapping(16) == 4) // val z = 3 } diff --git a/metals-runner/src/main/scala/org/scastie/metals/DTOExtensions.scala b/metals-runner/src/main/scala/org/scastie/metals/DTOExtensions.scala index a6bb37f4a..3dae30d9c 100644 --- a/metals-runner/src/main/scala/org/scastie/metals/DTOExtensions.scala +++ b/metals-runner/src/main/scala/org/scastie/metals/DTOExtensions.scala @@ -15,30 +15,34 @@ object DTOExtensions { def toOffsetParams: (CompilerOffsetParams, Boolean) = { val noSourceFilePath = Path.of(NoSourceFile.path) - val (content, position, insideWrapper) = if offsetParams.isWorksheetMode then - - val (usingDirectivesLines, remainingLines) = offsetParams.content.linesWithSeparators.span: - case line if line.startsWith("//>") => true - case _ => false - - val (usingDirectives, remainingCode) = (usingDirectivesLines.mkString, remainingLines.mkString) - val wrapperObject = s"""|object worksheet { - |$wrapperIndent""".stripMargin - - val adjustedContent = s"""$usingDirectives$wrapperObject${remainingCode.replace("\n", "\n" + wrapperIndent)}}""" - - if (offsetParams.offset < usingDirectives.length) then - (adjustedContent, offsetParams.offset, false) - else - val offsetWithoutDirectives = offsetParams.offset - usingDirectives.length - val contentToOffset = remainingCode.take(offsetWithoutDirectives).linesWithSeparators - val line = contentToOffset.size - 1 - val adjustedPosition = wrapperObject.length + line * 2 + offsetParams.offset - - (adjustedContent, adjustedPosition, true) - else (offsetParams.content, offsetParams.offset, false) - - (CompilerOffsetParams(noSourceFilePath.toUri, content, position, EmptyCancelToken, java.util.Optional.empty()), insideWrapper) + val (content, position, insideWrapper) = + if offsetParams.isWorksheetMode then + + val (usingDirectivesLines, remainingLines) = offsetParams.content.linesWithSeparators.span: + case line if line.startsWith("//>") => true + case _ => false + + val (usingDirectives, remainingCode) = (usingDirectivesLines.mkString, remainingLines.mkString) + val wrapperObject = s"""|object worksheet { + |$wrapperIndent""".stripMargin + + val adjustedContent = + s"""$usingDirectives$wrapperObject${remainingCode.replace("\n", "\n" + wrapperIndent)}}""" + + if (offsetParams.offset < usingDirectives.length) then (adjustedContent, offsetParams.offset, false) + else + val offsetWithoutDirectives = offsetParams.offset - usingDirectives.length + val contentToOffset = remainingCode.take(offsetWithoutDirectives).linesWithSeparators + val line = contentToOffset.size - 1 + val adjustedPosition = wrapperObject.length + line * 2 + offsetParams.offset + + (adjustedContent, adjustedPosition, true) + else (offsetParams.content, offsetParams.offset, false) + + ( + CompilerOffsetParams(noSourceFilePath.toUri, content, position, EmptyCancelToken, java.util.Optional.empty()), + insideWrapper + ) } } diff --git a/metals-runner/src/main/scala/org/scastie/metals/JavaConverters.scala b/metals-runner/src/main/scala/org/scastie/metals/JavaConverters.scala index 56d35c76f..ea74c61ea 100644 --- a/metals-runner/src/main/scala/org/scastie/metals/JavaConverters.scala +++ b/metals-runner/src/main/scala/org/scastie/metals/JavaConverters.scala @@ -4,14 +4,14 @@ import scala.jdk.CollectionConverters._ import scala.meta.internal.pc.CompletionItemData import com.google.gson.Gson -import org.scastie.api._ import org.eclipse.lsp4j._ import org.eclipse.lsp4j.jsonrpc.messages.{Either => JEither} +import org.scastie.api._ import org.slf4j.LoggerFactory object JavaConverters { private val logger = LoggerFactory.getLogger(getClass) - private val gson = new Gson() + private val gson = new Gson() extension [A, B](either: JEither[A, B]) @@ -36,8 +36,8 @@ object JavaConverters { extension (range: Range) { def toScalaRange(insideWrapper: Boolean) = - val start = range.getStart() - val end = range.getEnd() + val start = range.getStart() + val end = range.getEnd() val lineOffset = if insideWrapper then 0 else 1 val charOffset = if insideWrapper then -DTOExtensions.wrapperIndent.length else 0 EditRange( @@ -58,7 +58,7 @@ object JavaConverters { case Left(textEdit) => Some( InsertInstructions( textEdit.getNewText, - textEdit.getRange.toScalaRange(insideWrapper), + textEdit.getRange.toScalaRange(insideWrapper) ) ) case Right(insertReplace) => @@ -81,31 +81,30 @@ object JavaConverters { }) .toList - val completionItems = - for { - completion <- completions.getItems().asScala - filterText <- Option(completion.getFilterText()) - detail <- Option(completion.getDetail()) - kind <- Option(completion.getKind()).map(_.toString.toLowerCase) - order <- Option(completion.getSortText()).map(_.toIntOption) - insertInstructions <- createInsertInstructions(completion) - additionalInsertInstructions = createAdditionalInsertInstructions(completion) - data = parseCompletionData(completion) - } yield CompletionItemDTO( - filterText, - detail, - kind, - order, - insertInstructions, - additionalInsertInstructions, - data - ) + val completionItems = for { + completion <- completions.getItems().asScala + filterText <- Option(completion.getFilterText()) + detail <- Option(completion.getDetail()) + kind <- Option(completion.getKind()).map(_.toString.toLowerCase) + order <- Option(completion.getSortText()).map(_.toIntOption) + insertInstructions <- createInsertInstructions(completion) + additionalInsertInstructions = createAdditionalInsertInstructions(completion) + data = parseCompletionData(completion) + } yield CompletionItemDTO( + filterText, + detail, + kind, + order, + insertInstructions, + additionalInsertInstructions, + data + ) ScalaCompletionList(completionItems.toSet, completions.isIncomplete) } - def parseCompletionData(completion: CompletionItem): Option[String] = - Option(completion.getData()).map { completionData => + def parseCompletionData(completion: CompletionItem): Option[String] = Option(completion.getData()).map { + completionData => gson.fromJson(completionData.toString, classOf[CompletionItemData]).symbol - } + } } diff --git a/metals-runner/src/main/scala/org/scastie/metals/MetalsDispatcher.scala b/metals-runner/src/main/scala/org/scastie/metals/MetalsDispatcher.scala index d457b7aba..5ecb944cc 100644 --- a/metals-runner/src/main/scala/org/scastie/metals/MetalsDispatcher.scala +++ b/metals-runner/src/main/scala/org/scastie/metals/MetalsDispatcher.scala @@ -4,6 +4,7 @@ import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths import scala.concurrent.duration.* +import scala.concurrent.ExecutionContext import scala.concurrent.Future import scala.jdk.CollectionConverters._ import scala.meta.internal.metals.Embedded @@ -17,12 +18,11 @@ import cats.data.OptionT import cats.effect.{Async, Sync} import cats.syntax.all._ import com.evolutiongaming.scache.{Cache, Releasable} +import com.typesafe.config.ConfigFactory +import coursierapi.{Dependency, Fetch} import org.scastie.api._ import org.scastie.api.ScalaTarget._ -import coursierapi.{Dependency, Fetch} import org.slf4j.LoggerFactory -import scala.concurrent.ExecutionContext -import com.typesafe.config.ConfigFactory /* * MetalsDispatcher is responsible for managing the lifecycle of presentation compilers. @@ -35,8 +35,8 @@ import com.typesafe.config.ConfigFactory class MetalsDispatcher[F[_]: Async](cache: Cache[F, ScastieMetalsOptions, ScastiePresentationCompiler]) { private val logger = LoggerFactory.getLogger(getClass) - private val config = ConfigFactory.load().getConfig("scastie.metals") - private val isDocker = config.getBoolean("is-docker") + private val config = ConfigFactory.load().getConfig("scastie.metals") + private val isDocker = config.getBoolean("is-docker") private val lastMtags3Version = "3.3.3" private val mtagsResolver = new MtagsResolver.Default: @@ -50,16 +50,18 @@ class MetalsDispatcher[F[_]: Async](cache: Cache[F, ScastieMetalsOptions, Scasti logger.info(s"Metals working directory: $metalsWorkingDirectory") private val presentationCompilers = PresentationCompilers[F](metalsWorkingDirectory) - private val supportedVersions = Set("2.12", "2.13", "3") + private val supportedVersions = Set("2.12", "2.13", "3") - def getMtags(scalaVersion: String)= + def getMtags(scalaVersion: String) = for given ExecutionContext <- Sync[F].executionContext - mtags <- Sync[F].blocking( - mtagsResolver - .resolve(scalaVersion) - .toRight(PresentationCompilerFailure(s"Mtags couldn't be resolved for target: ${scalaVersion}.")) - ).recover { case err: MatchError => PresentationCompilerFailure(err.getMessage).asLeft } + mtags <- Sync[F] + .blocking( + mtagsResolver + .resolve(scalaVersion) + .toRight(PresentationCompilerFailure(s"Mtags couldn't be resolved for target: ${scalaVersion}.")) + ) + .recover { case err: MatchError => PresentationCompilerFailure(err.getMessage).asLeft } yield mtags /* @@ -70,31 +72,29 @@ class MetalsDispatcher[F[_]: Async](cache: Cache[F, ScastieMetalsOptions, Scasti * @param configuration - scastie client configuration * @returns `EitherT[F, FailureType, ScastiePresentationCompiler]` */ - def getCompiler(configuration: ScastieMetalsOptions): EitherT[F, FailureType, ScastiePresentationCompiler] = - EitherT: - if !isSupportedVersion(configuration) then - Async[F].delay( - PresentationCompilerFailure( - s"Interactive features are not supported for Scala ${configuration.scalaTarget.binaryScalaVersion}." - ).asLeft - ) - else - cache - .contains(configuration) - .flatMap: isCached => - if isCached then - cache - .get(configuration) - .map(_.toRight(PresentationCompilerFailure("Can't extract presentation compiler from cache."))) - else - for - mtags <- EitherT(getMtags(configuration.scalaTarget.scalaVersion)) - compiler <- EitherT.right( - cache.getOrUpdateReleasable(configuration) { - initializeCompiler(configuration, mtags).map: newPC => - Releasable(newPC, Sync[F].delay(newPC.underlyingPC.shutdown())) - }) - yield compiler + def getCompiler(configuration: ScastieMetalsOptions): EitherT[F, FailureType, ScastiePresentationCompiler] = EitherT: + if !isSupportedVersion(configuration) then + Async[F].delay( + PresentationCompilerFailure( + s"Interactive features are not supported for Scala ${configuration.scalaTarget.binaryScalaVersion}." + ).asLeft + ) + else + cache + .contains(configuration) + .flatMap: isCached => + if isCached then + cache + .get(configuration) + .map(_.toRight(PresentationCompilerFailure("Can't extract presentation compiler from cache."))) + else + for + mtags <- EitherT(getMtags(configuration.scalaTarget.scalaVersion)) + compiler <- EitherT.right(cache.getOrUpdateReleasable(configuration) { + initializeCompiler(configuration, mtags).map: newPC => + Releasable(newPC, Sync[F].delay(newPC.underlyingPC.shutdown())) + }) + yield compiler .value /* @@ -120,7 +120,6 @@ class MetalsDispatcher[F[_]: Async](cache: Cache[F, ScastieMetalsOptions, Scasti if configuration.scalaTarget.isInstanceOf[Js] then scalaTarget.isInstanceOf[Js] else true - val misconfiguredLibraries = configuration.dependencies .filterNot(l => checkScalaVersionCompatibility(l.target) && checkScalaJsCompatibility(l.target)) @@ -198,7 +197,8 @@ class MetalsDispatcher[F[_]: Async](cache: Cache[F, ScastieMetalsOptions, Scasti case Js(scalaVersion, scalaJsVersion) if scalaVersion.startsWith("3") => Set(Dependency.of("org.scala-js", "scalajs-library_2.13", scalaJsVersion)) case Js(scalaVersion, scalaJsVersion) => Set( - Dependency.of("org.scala-js", artifactWithBinaryVersion("scalajs-library", Scala2(scalaVersion)), scalaJsVersion) + Dependency + .of("org.scala-js", artifactWithBinaryVersion("scalajs-library", Scala2(scalaVersion)), scalaJsVersion) ) case _ => Set.empty @@ -216,8 +216,7 @@ class MetalsDispatcher[F[_]: Async](cache: Cache[F, ScastieMetalsOptions, Scasti val dep = dependencies.map { case ScalaDependency(groupId, artifact, target, version, true) => Dependency.of(groupId, artifactWithBinaryVersion(artifact, target), version) - case ScalaDependency(groupId, artifact, target, version, false) => - Dependency.of(groupId, artifact, version) + case ScalaDependency(groupId, artifact, target, version, false) => Dependency.of(groupId, artifact, version) }.toSeq ++ extraDependencies Fetch diff --git a/metals-runner/src/main/scala/org/scastie/metals/PresentationCompilers.scala b/metals-runner/src/main/scala/org/scastie/metals/PresentationCompilers.scala index 513bc710f..153c5d899 100644 --- a/metals-runner/src/main/scala/org/scastie/metals/PresentationCompilers.scala +++ b/metals-runner/src/main/scala/org/scastie/metals/PresentationCompilers.scala @@ -39,7 +39,7 @@ object BlockingServiceLoader { // NOTE(olafur): ServiceLoader doesn't find the service on Appveyor for // some reason, I'm unable to reproduce on my computer. Here below we // fallback to manual classloading. - val cls = classloader.loadClass(className) + val cls = classloader.loadClass(className) val ctor = cls.getDeclaredConstructor() ctor.setAccessible(true) ctor.newInstance().asInstanceOf[T] @@ -92,10 +92,12 @@ class PresentationCompilers[F[_]: Async](metalsWorkingDirectory: Path) { } private def prepareClasspathSearch(classpath: Seq[Path], version: String): F[ClasspathSearch] = Sync[F].delay { - classpath.filter(isSourceJar).foreach { path => { - val libVersion = ScalaVersions.scalaBinaryVersionFromJarName(path.getFileName.toString).getOrElse(version) - index.addSourceJar(AbsolutePath(path), ScalaVersions.dialectForScalaVersion(libVersion, true)) - }} + classpath.filter(isSourceJar).foreach { path => + { + val libVersion = ScalaVersions.scalaBinaryVersionFromJarName(path.getFileName.toString).getOrElse(version) + index.addSourceJar(AbsolutePath(path), ScalaVersions.dialectForScalaVersion(libVersion, true)) + } + } ClasspathSearch.fromClasspath(classpath.filterNot(isSourceJar), ExcludedPackagesHandler.default) } diff --git a/metals-runner/src/main/scala/org/scastie/metals/ScastieMetals.scala b/metals-runner/src/main/scala/org/scastie/metals/ScastieMetals.scala index a5a5784f9..dbc99efb8 100644 --- a/metals-runner/src/main/scala/org/scastie/metals/ScastieMetals.scala +++ b/metals-runner/src/main/scala/org/scastie/metals/ScastieMetals.scala @@ -5,8 +5,8 @@ import cats.data.OptionT import cats.effect.Async import cats.syntax.all._ import com.evolutiongaming.scache.Cache -import org.scastie.api._ import org.eclipse.lsp4j._ +import org.scastie.api._ trait ScastieMetals[F[_]]: def complete(request: LSPRequestDTO): EitherT[F, FailureType, ScalaCompletionList] @@ -34,7 +34,7 @@ object ScastieMetalsImpl: dispatcher.getCompiler(request.options) >>= (_.signatureHelp(request.offsetParams)) def isConfigurationSupported(config: ScastieMetalsOptions): EitherT[F, FailureType, Boolean] = - dispatcher.areDependenciesSupported(config) >>= - (_ => dispatcher.getCompiler(config).map(_ => true)) + dispatcher.areDependenciesSupported(config) >>= + (_ => dispatcher.getCompiler(config).map(_ => true)) } diff --git a/metals-runner/src/main/scala/org/scastie/metals/ScastieMetalsRoutes.scala b/metals-runner/src/main/scala/org/scastie/metals/ScastieMetalsRoutes.scala index 1786df0c7..b2aff3898 100644 --- a/metals-runner/src/main/scala/org/scastie/metals/ScastieMetalsRoutes.scala +++ b/metals-runner/src/main/scala/org/scastie/metals/ScastieMetalsRoutes.scala @@ -2,15 +2,14 @@ package org.scastie.metals import cats.effect.Async import cats.syntax.all._ -import org.scastie.api._ -import io.circe.syntax._ import io.circe.disjunctionCodecs.encodeEither - +import io.circe.syntax._ import org.http4s._ import org.http4s.circe._ import org.http4s.dsl.io._ import org.http4s.dsl.Http4sDsl import org.http4s.ember.server._ +import org.scastie.api._ object ScastieMetalsRoutes { @@ -19,39 +18,39 @@ object ScastieMetalsRoutes { import dsl._ import JavaConverters._ - implicit val lspRequestDecoder: EntityDecoder[F, LSPRequestDTO] = jsonOf[F, LSPRequestDTO] + implicit val lspRequestDecoder: EntityDecoder[F, LSPRequestDTO] = jsonOf[F, LSPRequestDTO] implicit val scastieMetalsOptionsDecoder: EntityDecoder[F, ScastieMetalsOptions] = jsonOf[F, ScastieMetalsOptions] - implicit val completionInfoDecoder: EntityDecoder[F, CompletionInfoRequest] = jsonOf[F, CompletionInfoRequest] + implicit val completionInfoDecoder: EntityDecoder[F, CompletionInfoRequest] = jsonOf[F, CompletionInfoRequest] HttpRoutes.of[F] { case req @ POST -> Root / "metals" / "complete" => for { - lspRequest <- req.as[LSPRequestDTO] + lspRequest <- req.as[LSPRequestDTO] maybeCompletions <- metals.complete(lspRequest).value - resp <- Ok(maybeCompletions.asJson) + resp <- Ok(maybeCompletions.asJson) } yield resp case req @ POST -> Root / "metals" / "completionItemResolve" => for { completionInfoRequest <- req.as[CompletionInfoRequest] - maybeCompletionInfo <- metals.completionInfo(completionInfoRequest).value - resp <- Ok(maybeCompletionInfo.asJson) + maybeCompletionInfo <- metals.completionInfo(completionInfoRequest).value + resp <- Ok(maybeCompletionInfo.asJson) } yield resp case req @ POST -> Root / "metals" / "hover" => for { lspRequest <- req.as[LSPRequestDTO] - hover <- metals.hover(lspRequest).value - resp <- Ok(hover.map(_.toHoverDTO).asJson) + hover <- metals.hover(lspRequest).value + resp <- Ok(hover.map(_.toHoverDTO).asJson) } yield resp case req @ POST -> Root / "metals" / "signatureHelp" => for { - lspRequest <- req.as[LSPRequestDTO] + lspRequest <- req.as[LSPRequestDTO] signatureHelp <- metals.signatureHelp(lspRequest).value - resp <- Status.NotImplemented() + resp <- Status.NotImplemented() } yield resp case req @ POST -> Root / "metals" / "isConfigurationSupported" => for { - scastieConfiguration <- req.as[ScastieMetalsOptions] + scastieConfiguration <- req.as[ScastieMetalsOptions] isConfigurationSupported <- metals.isConfigurationSupported(scastieConfiguration).value - resp <- Ok(isConfigurationSupported.asJson) + resp <- Ok(isConfigurationSupported.asJson) } yield resp } diff --git a/metals-runner/src/main/scala/org/scastie/metals/ScastiePresentationCompiler.scala b/metals-runner/src/main/scala/org/scastie/metals/ScastiePresentationCompiler.scala index 1d4d8def7..82638b604 100644 --- a/metals-runner/src/main/scala/org/scastie/metals/ScastiePresentationCompiler.scala +++ b/metals-runner/src/main/scala/org/scastie/metals/ScastiePresentationCompiler.scala @@ -13,8 +13,8 @@ import scala.meta.pc.PresentationCompiler import cats.data.EitherT import cats.effect.Async import cats.syntax.all._ -import org.scastie.api._ import org.eclipse.lsp4j._ +import org.scastie.api._ import org.slf4j.LoggerFactory import DTOExtensions._ import JavaConverters._ diff --git a/metals-runner/src/main/scala/org/scastie/metals/Server.scala b/metals-runner/src/main/scala/org/scastie/metals/Server.scala index 2b80fd20b..423f44e1e 100644 --- a/metals-runner/src/main/scala/org/scastie/metals/Server.scala +++ b/metals-runner/src/main/scala/org/scastie/metals/Server.scala @@ -10,19 +10,19 @@ import cats.effect.implicits.* import cats.syntax.all._ import com.comcast.ip4s._ import com.evolutiongaming.scache.{Cache, ExpiringCache} -import org.scastie.api.ScastieMetalsOptions import com.typesafe.config.ConfigFactory import fs2.Stream import org.http4s.ember.client.EmberClientBuilder import org.http4s.ember.server.EmberServerBuilder import org.http4s.implicits._ import org.http4s.server.middleware._ +import org.scastie.api.ScastieMetalsOptions object Server: - val config = ConfigFactory.load().getConfig("scastie.metals") + val config = ConfigFactory.load().getConfig("scastie.metals") val cacheExpirationInSeconds = config.getInt("cache-expire-in-seconds") - val serverPort = config.getInt("port") + val serverPort = config.getInt("port") def stream[F[_]: Async]: Stream[F, Nothing] = { val cache = Cache.expiring[F, ScastieMetalsOptions, ScastiePresentationCompiler]( @@ -31,8 +31,8 @@ object Server: ) val finalHttpApp = (cache0: Cache[F, ScastieMetalsOptions, ScastiePresentationCompiler]) => { - val metalsImpl = ScastieMetalsImpl.instance[F](cache0) - val httpApp = ScastieMetalsRoutes.routes[F](metalsImpl).orNotFound + val metalsImpl = ScastieMetalsImpl.instance[F](cache0) + val httpApp = ScastieMetalsRoutes.routes[F](metalsImpl).orNotFound val corsService = CORS.policy.withAllowOriginAll(httpApp) Logger.httpApp(true, false)(corsService) } diff --git a/metals-runner/src/test/scala/org/scastie/metals/MetalsDispatcherTest.scala b/metals-runner/src/test/scala/org/scastie/metals/MetalsDispatcherTest.scala index 4c92784d3..3a46e82ba 100644 --- a/metals-runner/src/test/scala/org/scastie/metals/MetalsDispatcherTest.scala +++ b/metals-runner/src/test/scala/org/scastie/metals/MetalsDispatcherTest.scala @@ -12,11 +12,11 @@ import cats.effect.IO.asyncForIO import cats.implicits._ import cats.syntax.all._ import com.evolutiongaming.scache.{Cache, ExpiringCache} -import org.scastie.api._ -import org.scastie.buildinfo.BuildInfo import munit.Assertions import munit.CatsEffectAssertions import munit.CatsEffectSuite +import org.scastie.api._ +import org.scastie.buildinfo.BuildInfo class MetalsDispatcherTest extends CatsEffectSuite with Assertions with CatsEffectAssertions { private val dispatcherF = @@ -32,7 +32,7 @@ class MetalsDispatcherTest extends CatsEffectSuite with Assertions with CatsEffe test("single thread metals access") { cache.use { cache => val dispatcher = dispatcherF(cache) - val options = ScastieMetalsOptions(Set.empty, Scala3(BuildInfo.latestLTS), "") + val options = ScastieMetalsOptions(Set.empty, Scala3(BuildInfo.latestLTS), "") assertIO(dispatcher.getCompiler(options).isRight, true) } } @@ -40,7 +40,7 @@ class MetalsDispatcherTest extends CatsEffectSuite with Assertions with CatsEffe test("parallel metals access for same cache entry") { cache.use { cache => val dispatcher = dispatcherF(cache) - val options = ScastieMetalsOptions(Set.empty, Scala3(BuildInfo.latestLTS), "") + val options = ScastieMetalsOptions(Set.empty, Scala3(BuildInfo.latestLTS), "") val tasks = List .fill(10)(dispatcher.getCompiler(options).flatMap(_.complete(ScastieOffsetParams("prin", 4, true))).value) .parSequence @@ -57,10 +57,10 @@ class MetalsDispatcherTest extends CatsEffectSuite with Assertions with CatsEffe cache.use { cache => { val dispatcher = dispatcherF(cache) - val options = ScastieMetalsOptions(Set.empty, Scala3(BuildInfo.latestLTS), "") + val options = ScastieMetalsOptions(Set.empty, Scala3(BuildInfo.latestLTS), "") val task = for { - pc <- dispatcher.getCompiler(options) - _ <- EitherT.right(IO.sleep(4.seconds)) + pc <- dispatcher.getCompiler(options) + _ <- EitherT.right(IO.sleep(4.seconds)) result <- EitherT.right(pc.complete(ScastieOffsetParams("print", 3, true))) } yield { result.items } interceptIO[java.util.concurrent.CancellationException](task.value) @@ -71,8 +71,8 @@ class MetalsDispatcherTest extends CatsEffectSuite with Assertions with CatsEffe test("parallel metals access same version") { cache.use { cache => val dispatcher = dispatcherF(cache) - val options = ScastieMetalsOptions(Set.empty, Scala3(BuildInfo.latestLTS), "") - val task = dispatcher.getCompiler(options).value.parReplicateA(10000) + val options = ScastieMetalsOptions(Set.empty, Scala3(BuildInfo.latestLTS), "") + val task = dispatcher.getCompiler(options).value.parReplicateA(10000) assertIO(task.map(results => results.nonEmpty && results.forall(_.isRight)), true) } } @@ -113,7 +113,8 @@ class MetalsDispatcherTest extends CatsEffectSuite with Assertions with CatsEffe ScalaDependency("io.monix", "monix", _, "3.4.1") ) - val testCases = dependencies.flatMap(dep => targets.map(target => ScastieMetalsOptions(Set(dep(target)), target, ""))) + val testCases = + dependencies.flatMap(dep => targets.map(target => ScastieMetalsOptions(Set(dep(target)), target, ""))) cache.use { cache => val dispatcher = dispatcherF(cache) val task = List diff --git a/metals-runner/src/test/scala/org/scastie/metals/MetalsServerTest.scala b/metals-runner/src/test/scala/org/scastie/metals/MetalsServerTest.scala index 2948a5e6c..eabbeba0d 100644 --- a/metals-runner/src/test/scala/org/scastie/metals/MetalsServerTest.scala +++ b/metals-runner/src/test/scala/org/scastie/metals/MetalsServerTest.scala @@ -4,13 +4,12 @@ import scala.jdk.CollectionConverters._ import cats.effect._ import cats.syntax.all._ -import org.scastie.api._ -import org.scastie.buildinfo.BuildInfo import munit.CatsEffectSuite import org.eclipse.lsp4j.MarkupContent import org.http4s._ -import TestUtils._ +import org.scastie.api._ import org.scastie.buildinfo.BuildInfo +import TestUtils._ class MetalsServerTest extends CatsEffectSuite { private val catsVersion = "2.8.0" @@ -392,10 +391,9 @@ class MetalsServerTest extends CatsEffectSuite { | printl@@ |} """.stripMargin, - expectedCode = - """object M { - | println() - |} + expectedCode = """object M { + | println() + |} """.stripMargin, isWorksheet = false ) @@ -407,10 +405,9 @@ class MetalsServerTest extends CatsEffectSuite { | printl@@ |} """.stripMargin, - expectedCode = - """object M { - | println() - |} + expectedCode = """object M { + | println() + |} """.stripMargin, isWorksheet = true ) @@ -420,15 +417,14 @@ class MetalsServerTest extends CatsEffectSuite { test("Text edit is proper for worksheet with using directives") { testCompletionEdit( code = s"""//> using scala ${BuildInfo.stableNext} - | - |printl@@ - | + | + |printl@@ + | """.stripMargin, - expectedCode = - s"""//> using scala ${BuildInfo.stableNext} - | - |println() - | + expectedCode = s"""//> using scala ${BuildInfo.stableNext} + | + |println() + | """.stripMargin, isWorksheet = true ) @@ -437,15 +433,14 @@ class MetalsServerTest extends CatsEffectSuite { test("Text edit is proper for no workheet with using directives") { testCompletionEdit( code = s"""//> using scala ${BuildInfo.stableNext} - |object M { - | printl@@ - |} + |object M { + | printl@@ + |} """.stripMargin, - expectedCode = - s"""//> using scala ${BuildInfo.stableNext} - |object M { - | println() - |} + expectedCode = s"""//> using scala ${BuildInfo.stableNext} + |object M { + | println() + |} """.stripMargin, isWorksheet = false ) @@ -459,11 +454,10 @@ class MetalsServerTest extends CatsEffectSuite { | println() |} """.stripMargin, - expectedCode = - """//> using dep org.scala-lang - |object M { - | println() - |} + expectedCode = """//> using dep org.scala-lang + |object M { + | println() + |} """.stripMargin, isWorksheet = true ) @@ -477,11 +471,10 @@ class MetalsServerTest extends CatsEffectSuite { | println() |} """.stripMargin, - expectedCode = - """//> using dep org.scala-lang - |object M { - | println() - |} + expectedCode = """//> using dep org.scala-lang + |object M { + | println() + |} """.stripMargin, isWorksheet = false ) diff --git a/metals-runner/src/test/scala/org/scastie/metals/TestUtils.scala b/metals-runner/src/test/scala/org/scastie/metals/TestUtils.scala index 1c0e49b98..0ae21821f 100644 --- a/metals-runner/src/test/scala/org/scastie/metals/TestUtils.scala +++ b/metals-runner/src/test/scala/org/scastie/metals/TestUtils.scala @@ -2,30 +2,29 @@ package org.scastie.metals import scala.jdk.CollectionConverters._ +import cats.data.EitherT +import cats.data.OptionT import cats.effect.implicits.* import cats.effect.IO import cats.syntax.all._ import com.evolutiongaming.scache.Cache -import org.scastie.api._ -import org.scastie.api.{ScalaDependency, ScalaTarget} -import org.scastie.buildinfo.BuildInfo import munit.Assertions import munit.CatsEffectAssertions import org.eclipse.lsp4j.MarkupContent import org.http4s._ +import org.scastie.api._ +import org.scastie.api.{ScalaDependency, ScalaTarget} +import org.scastie.buildinfo.BuildInfo import JavaConverters._ -import cats.data.EitherT -import cats.data.OptionT object TestUtils extends Assertions with CatsEffectAssertions { - val cache = Cache.empty[IO, ScastieMetalsOptions, ScastiePresentationCompiler] + val cache = Cache.empty[IO, ScastieMetalsOptions, ScastiePresentationCompiler] val server = ScastieMetalsImpl.instance[IO](cache) type DependencyForVersion = ScalaTarget => ScalaDependency - val testTargets = - List(BuildInfo.latestLTS, BuildInfo.stableLTS, BuildInfo.latestNext).map(Scala3.apply) ++ - List(BuildInfo.latest213, BuildInfo.latest212).map(Scala2.apply) + val testTargets = List(BuildInfo.latestLTS, BuildInfo.stableLTS, BuildInfo.latestNext).map(Scala3.apply) ++ + List(BuildInfo.latest213, BuildInfo.latest212).map(Scala2.apply) val unsupportedVersions = List(BuildInfo.latest211, BuildInfo.latest210).map(Scala2.apply) @@ -41,12 +40,12 @@ object TestUtils extends Assertions with CatsEffectAssertions { isWorksheet: Boolean = false ): LSPRequestDTO = val offsetParamsComplete = testCode(code, isWorksheet) - val dependencies0 = dependencies.map(_.apply(scalaTarget)) + val dependencies0 = dependencies.map(_.apply(scalaTarget)) LSPRequestDTO(ScastieMetalsOptions(dependencies0, scalaTarget, code), offsetParamsComplete) def getCompat[A](scalaTarget: ScalaTarget, compat: Map[String, A], default: A): A = val binaryScalaVersion = scalaTarget.binaryScalaVersion - val majorVersion = binaryScalaVersion.split('.').headOption + val majorVersion = binaryScalaVersion.split('.').headOption if (compat.contains(scalaTarget.scalaVersion)) then compat(scalaTarget.scalaVersion) else if (compat.keys.exists(_ == binaryScalaVersion)) then compat(binaryScalaVersion) else if (majorVersion.forall(v => compat.keys.exists(_ == v))) compat(majorVersion.get) @@ -58,10 +57,10 @@ object TestUtils extends Assertions with CatsEffectAssertions { code: String = "", expected: Either[FailureType, Set[String]] = Right(Set()), compat: Map[String, Either[FailureType, Set[String]]] = Map(), - isWorksheet: Boolean = false, + isWorksheet: Boolean = false ): IO[List[Unit]] = testTargets.traverse(scalaTarget => val request = createRequest(scalaTarget, dependencies, code, isWorksheet) - val comp = server.complete(request).map(_.items.map(item => s"${item.label} ${item.detail}").toSet).value + val comp = server.complete(request).map(_.items.map(item => s"${item.label} ${item.detail}").toSet).value assertIO(comp, getCompat(scalaTarget, compat, expected), Left(NoResult(s"Failed for target $scalaTarget"))) ) @@ -70,13 +69,14 @@ object TestUtils extends Assertions with CatsEffectAssertions { dependencies: Set[DependencyForVersion] = Set(), code: String, expectedCode: String, - isWorksheet: Boolean, + isWorksheet: Boolean ): IO[List[Unit]] = testTargets.traverse(scalaTarget => val request = createRequest(scalaTarget, dependencies, code, isWorksheet) val newText = server.complete(request).map(_.items.head).map { completionDTO => val editRange = completionDTO.instructions.editRange val offset = code.linesWithSeparators.take(editRange.startLine - 1).map(_.length).sum + editRange.startChar - request.offsetParams.content.patch(offset, completionDTO.instructions.text, editRange.endChar - editRange.startChar) + request.offsetParams.content + .patch(offset, completionDTO.instructions.text, editRange.endChar - editRange.startChar) } assertIO(newText.value, expectedCode.asRight[FailureType], Left(NoResult(s"Failed for target $scalaTarget"))) ) @@ -89,7 +89,7 @@ object TestUtils extends Assertions with CatsEffectAssertions { compat: Map[String, Either[FailureType, MarkupContent]] = Map() ): IO[List[Unit]] = testTargets.traverse(scalaTarget => val request = createRequest(scalaTarget, dependencies, code) - val comp = server.hover(request).map(_.getContents().getRight()).value + val comp = server.hover(request).map(_.getContents().getRight()).value assertIO(comp, getCompat(scalaTarget, compat, expected), Left(NoResult(s"Failed for target $scalaTarget"))) ) diff --git a/runtime-api/src/main/scala/org/scastie/runtime/api/EscapeString.scala b/runtime-api/src/main/scala/org/scastie/runtime/api/EscapeString.scala index a8584f353..b0999cdf3 100644 --- a/runtime-api/src/main/scala/org/scastie/runtime/api/EscapeString.scala +++ b/runtime-api/src/main/scala/org/scastie/runtime/api/EscapeString.scala @@ -10,13 +10,14 @@ object StringUtils { case '\n' => "\\n" case '\f' => "\\f" case '\r' => "\\r" - case '"' => "\\\"" + case '"' => "\\\"" // case '\'' => "\\\'" case '\\' => "\\\\" - case _ => if (ch.isControl) f"${"\\"}u${ch.toInt}%04x" else String.valueOf(ch) + case _ => if (ch.isControl) f"${"\\"}u${ch.toInt}%04x" else String.valueOf(ch) } implicit class EscapedString(val s: String) { def escaped: String = s.flatMap(escapedChar) } + } diff --git a/runtime-api/src/main/scala/org/scastie/runtime/api/Instrumentation.scala b/runtime-api/src/main/scala/org/scastie/runtime/api/Instrumentation.scala index d6e4cdf29..5256be89a 100644 --- a/runtime-api/src/main/scala/org/scastie/runtime/api/Instrumentation.scala +++ b/runtime-api/src/main/scala/org/scastie/runtime/api/Instrumentation.scala @@ -3,11 +3,13 @@ package org.scastie.runtime.api import StringUtils._ sealed trait Render { + def asJsonString: String = this match { - case Value(v, className) => s"""{"Value":{"v":"${v.escaped}","className":"${className.escaped}"}}""" - case Html(a, folded) => s"""{"Html":{"a":"${a.escaped}","folded":$folded}}""" + case Value(v, className) => s"""{"Value":{"v":"${v.escaped}","className":"${className.escaped}"}}""" + case Html(a, folded) => s"""{"Html":{"a":"${a.escaped}","folded":$folded}}""" case AttachedDom(uuid, folded) => s"""{"AttachedDom":{"uuid":"$uuid","folded":$folded}}""" } + } case class Value(v: String, className: String) extends Render @@ -16,6 +18,7 @@ case class Html(a: String, folded: Boolean = false) extends Render { def stripMargin: Html = copy(a = a.stripMargin) def fold: Html = copy(folded = true) } + case class AttachedDom(uuid: String, folded: Boolean = false) extends Render { def fold: AttachedDom = copy(folded = true) } diff --git a/runtime-api/src/main/scala/org/scastie/runtime/api/RuntimeError.scala b/runtime-api/src/main/scala/org/scastie/runtime/api/RuntimeError.scala index b1b9258c4..8d5b99099 100644 --- a/runtime-api/src/main/scala/org/scastie/runtime/api/RuntimeError.scala +++ b/runtime-api/src/main/scala/org/scastie/runtime/api/RuntimeError.scala @@ -1,6 +1,7 @@ package org.scastie.runtime.api import java.io.{PrintWriter, StringWriter} + import StringUtils._ case class RuntimeError( @@ -8,9 +9,11 @@ case class RuntimeError( line: Option[Int], fullStack: String ) { + def asJsonString: String = { s"""{"message":"${message.escaped}","line":${line.getOrElse(null)},"fullStack":"${fullStack.escaped}"}""" } + } object RuntimeError { @@ -19,19 +22,16 @@ object RuntimeError { try { Right(in) } catch { - case ex: Exception => - Left(RuntimeError.fromThrowable(ex, fromScala = false)) + case ex: Exception => Left(RuntimeError.fromThrowable(ex, fromScala = false)) } } def fromThrowable(t: Throwable, fromScala: Boolean = true): Option[RuntimeError] = { def search(e: Throwable) = { e.getStackTrace - .find( - trace => - if (fromScala) - trace.getFileName == "main.scala" && trace.getLineNumber != -1 - else true + .find(trace => + if (fromScala) trace.getFileName == "main.scala" && trace.getLineNumber != -1 + else true ) .map(v => (e, Some(v.getLineNumber))) } @@ -43,15 +43,15 @@ object RuntimeError { else s } - loop(t).map { - case (err, line) => - val errors = new StringWriter() - t.printStackTrace(new PrintWriter(errors)) - val fullStack = errors.toString + loop(t).map { case (err, line) => + val errors = new StringWriter() + t.printStackTrace(new PrintWriter(errors)) + val fullStack = errors.toString - RuntimeError(err.toString, line, fullStack) + RuntimeError(err.toString, line, fullStack) } } + } case class RuntimeErrorWrap(error: Option[RuntimeError]) diff --git a/runtime-api/src/main/scala/org/scastie/runtime/api/ScalaJsResult.scala b/runtime-api/src/main/scala/org/scastie/runtime/api/ScalaJsResult.scala index cf571ca3b..cd3d250ff 100644 --- a/runtime-api/src/main/scala/org/scastie/runtime/api/ScalaJsResult.scala +++ b/runtime-api/src/main/scala/org/scastie/runtime/api/ScalaJsResult.scala @@ -1,10 +1,12 @@ package org.scastie.runtime.api case class ScalaJsResult(instrumentations: List[Instrumentation], error: Option[RuntimeError]) { + def asJsonString: String = error match { case Some(value) => s"""{"error":${value.asJsonString}}""" - case None => + case None => val instrumentationJson = instrumentations.map(_.asJsonString).mkString("[", ",", "]") s"""{"instrumentations":$instrumentationJson}""" } + } diff --git a/runtime-scala/src/main/scala/scastie/runtime/InstrumentationRecorder.scala b/runtime-scala/src/main/scala/scastie/runtime/InstrumentationRecorder.scala index 6354ed2d6..afb3ccbe3 100644 --- a/runtime-scala/src/main/scala/scastie/runtime/InstrumentationRecorder.scala +++ b/runtime-scala/src/main/scala/scastie/runtime/InstrumentationRecorder.scala @@ -8,8 +8,8 @@ import org.scastie.runtime.api._ trait InstrumentationRecorder { - private val myBinders = ArrayBuffer.empty[Binder] - val myStatements = ArrayBuffer.empty[Statement] + private val myBinders = ArrayBuffer.empty[Binder] + val myStatements = ArrayBuffer.empty[Statement] private var statementPosition = Position(0, 0) object $doc { diff --git a/runtime-scala/src/main/scala/scastie/runtime/SharedRuntime.scala b/runtime-scala/src/main/scala/scastie/runtime/SharedRuntime.scala index f2dd175c6..67befd668 100644 --- a/runtime-scala/src/main/scala/scastie/runtime/SharedRuntime.scala +++ b/runtime-scala/src/main/scala/scastie/runtime/SharedRuntime.scala @@ -30,7 +30,7 @@ protected[runtime] trait SharedRuntime { protected[runtime] def render[T](a: T, typeName: String): Render = { a match { case html: Html => html - case v => + case v => val vs = show(v) val out = if (vs.size > maxValueLength) vs.take(maxValueLength) + "..." @@ -39,4 +39,5 @@ protected[runtime] trait SharedRuntime { Value(out, typeName.replace(Instrumentation.instrumentedObject + ".", "")) } } + } diff --git a/runtime-scala/src/main/scalajs/scastie/runtime/DomHook.scala b/runtime-scala/src/main/scalajs/scastie/runtime/DomHook.scala index 8ff8568e2..ebfa73d38 100644 --- a/runtime-scala/src/main/scalajs/scastie/runtime/DomHook.scala +++ b/runtime-scala/src/main/scalajs/scastie/runtime/DomHook.scala @@ -1,11 +1,10 @@ package org.scastie.runtime -import org.scalajs.dom.HTMLElement -import scala.scalajs.js - +import java.util.UUID import scala.collection.mutable.Buffer +import scala.scalajs.js -import java.util.UUID +import org.scalajs.dom.HTMLElement trait DomHook { private val elements = Buffer.empty[HTMLElement] diff --git a/runtime-scala/src/main/scalajs/scastie/runtime/Runtime.scala b/runtime-scala/src/main/scalajs/scastie/runtime/Runtime.scala index a4384327a..adccde754 100644 --- a/runtime-scala/src/main/scalajs/scastie/runtime/Runtime.scala +++ b/runtime-scala/src/main/scalajs/scastie/runtime/Runtime.scala @@ -1,9 +1,10 @@ package org.scastie.runtime -import org.scalajs.dom.HTMLElement -import java.util.UUID import java.awt.image.BufferedImage +import java.util.UUID import scala.reflect.ClassTag + +import org.scalajs.dom.HTMLElement import org.scastie.runtime.api._ object Runtime extends SharedRuntime { @@ -11,11 +12,13 @@ object Runtime extends SharedRuntime { def write(in: Either[Option[RuntimeError], List[Instrumentation]]): String = { in match { case Right(instrumentations) => ScalaJsResult(instrumentations, None).asJsonString - case Left(error) => ScalaJsResult(Nil, error).asJsonString + case Left(error) => ScalaJsResult(Nil, error).asJsonString } } - def render[T](a: T, attach: HTMLElement => UUID)(implicit _ct: ClassTag[T] = null): Render = { + def render[T](a: T, attach: HTMLElement => UUID)( + implicit _ct: ClassTag[T] = null + ): Render = { val ct = Option(_ct) a match { case element: HTMLElement => { @@ -26,11 +29,10 @@ object Runtime extends SharedRuntime { } } - def image(path: String): Html = - throw new Exception("image(path: String): Html works only on the jvm") + def image(path: String): Html = throw new Exception("image(path: String): Html works only on the jvm") + + def toBase64(in: BufferedImage): Html = throw new Exception( + "toBase64(in: BufferedImage): Html works only on the jvm" + ) - def toBase64(in: BufferedImage): Html = - throw new Exception( - "toBase64(in: BufferedImage): Html works only on the jvm" - ) } diff --git a/runtime-scala/src/main/scalajvm-2/scastie/runtime/Runtime.scala b/runtime-scala/src/main/scalajvm-2/scastie/runtime/Runtime.scala index c839f7df4..521b9d2db 100644 --- a/runtime-scala/src/main/scalajvm-2/scastie/runtime/Runtime.scala +++ b/runtime-scala/src/main/scalajvm-2/scastie/runtime/Runtime.scala @@ -1,13 +1,19 @@ package org.scastie.runtime -import scala.reflect.ClassTag import scala.reflect.runtime.universe._ +import scala.reflect.ClassTag + import org.scastie.runtime.api._ object Runtime extends JvmRuntime { - def render[T](a: T)(implicit _ct: ClassTag[T] = null, _tt: TypeTag[T] = null): Render = { + + def render[T](a: T)( + implicit _ct: ClassTag[T] = null, + _tt: TypeTag[T] = null + ): Render = { val ct = Option(_ct) val tt = Option(_tt) super.render(a, tt.map(_.tpe.toString).orElse(ct.map(_.toString)).getOrElse("")) } + } diff --git a/runtime-scala/src/main/scalajvm-3/scastie/runtime/Runtime.scala b/runtime-scala/src/main/scalajvm-3/scastie/runtime/Runtime.scala index 3ae42d7c8..33c962010 100644 --- a/runtime-scala/src/main/scalajvm-3/scastie/runtime/Runtime.scala +++ b/runtime-scala/src/main/scalajvm-3/scastie/runtime/Runtime.scala @@ -1,12 +1,15 @@ package org.scastie.runtime import scala.quoted.* + import org.scastie.runtime.api.Render object Runtime extends JvmRuntime: - inline def render[T](a: T): Render = ${_render('a)} + inline def render[T](a: T): Render = ${ _render('a) } - private def _render[T: Type](a: Expr[T])(using Quotes): Expr[Render] = + private def _render[T: Type](a: Expr[T])( + using Quotes + ): Expr[Render] = import quotes.reflect.* val t = TypeRepr.of[T] - '{Runtime.render($a, ${Expr(t.show)})} + '{ Runtime.render($a, ${ Expr(t.show) }) } diff --git a/runtime-scala/src/main/scalajvm/scastie/runtime/Runtime.scala b/runtime-scala/src/main/scalajvm/scastie/runtime/Runtime.scala index 8239fb14a..8b884c5e4 100644 --- a/runtime-scala/src/main/scalajvm/scastie/runtime/Runtime.scala +++ b/runtime-scala/src/main/scalajvm/scastie/runtime/Runtime.scala @@ -1,12 +1,14 @@ package org.scastie.runtime -import javax.imageio.ImageIO +import java.awt.image.BufferedImage import java.io.{ByteArrayOutputStream, File} import java.util.Base64 -import java.awt.image.BufferedImage + +import javax.imageio.ImageIO import org.scastie.runtime.api._ protected[runtime] trait JvmRuntime extends SharedRuntime { + def image(path: String): Html = { val in = ImageIO.read(new File(path)) toBase64(in) @@ -28,4 +30,5 @@ protected[runtime] trait JvmRuntime extends SharedRuntime { folded = true ) } + } diff --git a/sbt-runner/src/main/scala/org/scastie/sbt/FormatActor.scala b/sbt-runner/src/main/scala/org/scastie/sbt/FormatActor.scala index 37cec6065..1235a77d4 100644 --- a/sbt-runner/src/main/scala/org/scastie/sbt/FormatActor.scala +++ b/sbt-runner/src/main/scala/org/scastie/sbt/FormatActor.scala @@ -4,15 +4,13 @@ package sbt import scala.meta._ import akka.actor.Actor - +import org.scalafmt.config.NamedDialect +import org.scalafmt.config.ScalafmtConfig +import org.scalafmt.Formatted +import org.scalafmt.Scalafmt import org.scastie.api.FormatRequest import org.scastie.api.FormatResponse import org.scastie.api.ScalaTarget -import org.scalafmt.Formatted - -import org.scalafmt.Scalafmt -import org.scalafmt.config.ScalafmtConfig -import org.scalafmt.config.NamedDialect import org.slf4j.LoggerFactory object FormatActor { @@ -37,14 +35,14 @@ class FormatActor() extends Actor { import FormatActor._ private val log = LoggerFactory.getLogger(getClass) - override def receive: Receive = { - case api.FormatRequest(code, isWorksheetMode, scalaTarget) => - log.info(s"format (isWorksheetMode: $isWorksheetMode)") - log.info(code) + override def receive: Receive = { case api.FormatRequest(code, isWorksheetMode, scalaTarget) => + log.info(s"format (isWorksheetMode: $isWorksheetMode)") + log.info(code) - format(code, scalaTarget) match { - case Left(value) => sender() ! FormatResponse(code) - case Right(value) => sender() ! FormatResponse(value) - } + format(code, scalaTarget) match { + case Left(value) => sender() ! FormatResponse(code) + case Right(value) => sender() ! FormatResponse(value) + } } + } diff --git a/sbt-runner/src/main/scala/org/scastie/sbt/OutputExtractor.scala b/sbt-runner/src/main/scala/org/scastie/sbt/OutputExtractor.scala index 9b438e7a3..363889796 100644 --- a/sbt-runner/src/main/scala/org/scastie/sbt/OutputExtractor.scala +++ b/sbt-runner/src/main/scala/org/scastie/sbt/OutputExtractor.scala @@ -1,40 +1,42 @@ package org.scastie.sbt import java.time.Instant +import scala.meta.inputs.Input +import scala.util.control.NonFatal +import io.circe._ +import io.circe.generic.semiauto._ +import io.circe.parser._ +import io.circe.syntax._ import org.scastie.api._ -import org.scastie.runtime.api._ import org.scastie.instrumentation.Instrument +import org.scastie.runtime.api._ import org.scastie.sbt.SbtProcess._ import org.slf4j.LoggerFactory - -import io.circe._ -import io.circe.generic.semiauto._ -import io.circe.syntax._ -import io.circe.parser._ - import RuntimeCodecs._ -import scala.meta.inputs.Input -import scala.util.control.NonFatal +class OutputExtractor( + getScalaJsContent: () => Option[String], + getScalaJsSourceMapContent: () => Option[String], + isProduction: Boolean, + promptUniqueId: String +) { -class OutputExtractor(getScalaJsContent: () => Option[String], - getScalaJsSourceMapContent: () => Option[String], - isProduction: Boolean, - promptUniqueId: String) { def extractProgress(output: ProcessOutput, sbtRun: SbtRun, isReloading: Boolean): SnippetProgress = { import sbtRun._ - val problems = extractProblems(output.line, sbtRun, Instrument.getMessageLineOffset(inputs.isWorksheetMode, isScalaCli = false)) + val problems = + extractProblems(output.line, sbtRun, Instrument.getMessageLineOffset(inputs.isWorksheetMode, isScalaCli = false)) val instrumentations = extract[List[Instrumentation]](output.line) - val runtimeError = extractRuntimeError(output.line, sbtRun, Instrument.getExceptionLineOffset(inputs.isWorksheetMode)) + val runtimeError = + extractRuntimeError(output.line, sbtRun, Instrument.getExceptionLineOffset(inputs.isWorksheetMode)) val consoleOutput = extract[ConsoleOutput](output.line) // sbt plugin is not loaded at this stage. we need to drop those messages val hiddenInitializationMessages = List( "WARNING: A terminally deprecated method in java.lang.System has been called", "WARNING: System::setSecurityManager has been called", "WARNING: Please consider reporting this to the maintainers", - "WARNING: System::setSecurityManager will be removed in a future release", + "WARNING: System::setSecurityManager will be removed in a future release" ) val isHiddenSbtMessage = @@ -45,14 +47,15 @@ class OutputExtractor(getScalaJsContent: () => Option[String], val isScalaJs = inputs.target.targetType == ScalaTargetType.JS val userOutput = - if (problems.toList.flatten.isEmpty - && instrumentations.toList.flatten.isEmpty - && runtimeError.isEmpty - && !isDone - && !isHiddenSbtMessage - && !isReloading - && consoleOutput.isEmpty) - Some(output) + if ( + problems.toList.flatten.isEmpty + && instrumentations.toList.flatten.isEmpty + && runtimeError.isEmpty + && !isDone + && !isHiddenSbtMessage + && !isReloading + && consoleOutput.isEmpty + ) Some(output) else None val (scalaJsContent, scalaJsSourceMapContent) = @@ -66,11 +69,10 @@ class OutputExtractor(getScalaJsContent: () => Option[String], val isReallyDone = (isDone && !isReloading) || isSbtError - val sbtProcessOutput = - consoleOutput match { - case Some(SbtOutput(output)) if !isHiddenSbtMessage => Some(output) - case _ => None - } + val sbtProcessOutput = consoleOutput match { + case Some(SbtOutput(output)) if !isHiddenSbtMessage => Some(output) + case _ => None + } SnippetProgress( ts = Some(Instant.now.toEpochMilli), @@ -105,23 +107,21 @@ class OutputExtractor(getScalaJsContent: () => Option[String], ) private def remapSourceMap( - snippetId: SnippetId + snippetId: SnippetId )(sourceMapRaw: String): String = { decode[SourceMap](sourceMapRaw).toOption .map { sourceMap => - val sourceMap0 = - sourceMap.copy( - sources = sourceMap.sources.map( - source => - if (source.startsWith(Js.sourceUUID)) { - val host = - if (isProduction) "https://scastie.scala-lang.org" - else "http://localhost:9000" - - host + snippetId.scalaJsUrl(Js.sourceFilename) - } else source - ) + val sourceMap0 = sourceMap.copy( + sources = sourceMap.sources.map(source => + if (source.startsWith(Js.sourceUUID)) { + val host = + if (isProduction) "https://scastie.scala-lang.org" + else "http://localhost:9000" + + host + snippetId.scalaJsUrl(Js.sourceFilename) + } else source ) + ) sourceMap0.asJson.noSpaces } @@ -129,18 +129,14 @@ class OutputExtractor(getScalaJsContent: () => Option[String], } private def extractProblems( - line: String, - sbtRun: SbtRun, - lineOffset: Int + line: String, + sbtRun: SbtRun, + lineOffset: Int ): Option[List[Problem]] = { val problems = extract[List[Problem]](line) val problemsWithMappedLines = problems.map { - _.map(problem => - problem.copy(line = - problem.line.map(instrumentedLine => sbtRun.lineMapping(instrumentedLine)) - ) - ) + _.map(problem => problem.copy(line = problem.line.map(instrumentedLine => sbtRun.lineMapping(instrumentedLine)))) } def annoying(in: Problem): Boolean = { @@ -155,7 +151,8 @@ class OutputExtractor(getScalaJsContent: () => Option[String], private def extractRuntimeError(line: String, sbtRun: SbtRun, lineOffset: Int): Option[RuntimeError] = { extract[RuntimeErrorWrap](line).flatMap { _.error.map { error => - val noStackTraceError = if (error.message.contains("No main class detected.")) error.copy(fullStack = "") else error + val noStackTraceError = + if (error.message.contains("No main class detected.")) error.copy(fullStack = "") else error val errorWithMappedLine = noStackTraceError.copy( line = noStackTraceError.line.map(instrumentedLine => sbtRun.lineMapping(instrumentedLine)) ) diff --git a/sbt-runner/src/main/scala/org/scastie/sbt/SbtActor.scala b/sbt-runner/src/main/scala/org/scastie/sbt/SbtActor.scala index d61d096e5..96a988f48 100644 --- a/sbt-runner/src/main/scala/org/scastie/sbt/SbtActor.scala +++ b/sbt-runner/src/main/scala/org/scastie/sbt/SbtActor.scala @@ -1,22 +1,23 @@ package org.scastie.sbt +import scala.concurrent.duration._ + +import akka.actor.{Actor, ActorContext, ActorLogging, ActorRef, ActorSelection, ActorSystem, Props} import org.scastie.api._ import org.scastie.util._ -import akka.actor.{Actor, ActorContext, ActorLogging, ActorRef, ActorSelection, ActorSystem, Props} - -import scala.concurrent.duration._ case object SbtActorReady -class SbtActor(system: ActorSystem, - runTimeout: FiniteDuration, - reloadTimeout: FiniteDuration, - isProduction: Boolean, - readyRef: Option[ActorRef], - override val reconnectInfo: Option[ReconnectInfo]) - extends Actor - with ActorLogging - with ActorReconnecting { +class SbtActor( + system: ActorSystem, + runTimeout: FiniteDuration, + reloadTimeout: FiniteDuration, + isProduction: Boolean, + readyRef: Option[ActorRef], + override val reconnectInfo: Option[ReconnectInfo] +) extends Actor + with ActorLogging + with ActorReconnecting { def balancer(context: ActorContext, info: ReconnectInfo): ActorSelection = { import info._ @@ -47,21 +48,19 @@ class SbtActor(system: ActorSystem, super.postStop() } - private val formatActor = - context.actorOf(Props(new FormatActor()), name = "FormatActor") - - private val sbtRunner = - context.actorOf( - Props( - new SbtProcess( - runTimeout, - reloadTimeout, - isProduction, - javaOptions = Seq("-Xms512m", "-Xmx1g") - ) - ), - name = "SbtRunner" - ) + private val formatActor = context.actorOf(Props(new FormatActor()), name = "FormatActor") + + private val sbtRunner = context.actorOf( + Props( + new SbtProcess( + runTimeout, + reloadTimeout, + isProduction, + javaOptions = Seq("-Xms512m", "-Xmx1g") + ) + ), + name = "SbtRunner" + ) override def receive: Receive = reconnectBehavior orElse [Any, Unit] { case RunnerPing => { @@ -92,4 +91,5 @@ class SbtActor(system: ActorSystem, } } } + } diff --git a/sbt-runner/src/main/scala/org/scastie/sbt/SbtMain.scala b/sbt-runner/src/main/scala/org/scastie/sbt/SbtMain.scala index ce1cbdc1c..82140affe 100644 --- a/sbt-runner/src/main/scala/org/scastie/sbt/SbtMain.scala +++ b/sbt-runner/src/main/scala/org/scastie/sbt/SbtMain.scala @@ -1,18 +1,17 @@ package org.scastie.sbt -import org.scastie.util.ScastieFileUtil.writeRunningPid -import org.scastie.util.ReconnectInfo +import java.util.concurrent.TimeUnit +import scala.concurrent.duration._ +import scala.concurrent.Await import akka.actor.{ActorSystem, Props} import com.typesafe.config.ConfigFactory - -import scala.concurrent.Await -import scala.concurrent.duration._ -import java.util.concurrent.TimeUnit - +import org.scastie.util.ReconnectInfo +import org.scastie.util.ScastieFileUtil.writeRunningPid import org.slf4j.LoggerFactory object SbtMain { + def main(args: Array[String]): Unit = { val logger = LoggerFactory.getLogger(getClass) @@ -54,13 +53,12 @@ object SbtMain { ) } - val reconnectInfo = - ReconnectInfo( - serverHostname = serverConfig.getString("hostname"), - serverAkkaPort = serverConfig.getInt("akka-port"), - actorHostname = sbtConfig.getString("hostname"), - actorAkkaPort = sbtConfig.getInt("akka-port") - ) + val reconnectInfo = ReconnectInfo( + serverHostname = serverConfig.getString("hostname"), + serverAkkaPort = serverConfig.getInt("akka-port"), + actorHostname = sbtConfig.getString("hostname"), + actorAkkaPort = sbtConfig.getInt("akka-port") + ) logger.info(" runTimeout: {}", runTimeout) logger.info(" reloadTimeout: {}", reloadTimeout) @@ -88,4 +86,5 @@ object SbtMain { () } + } diff --git a/sbt-runner/src/main/scala/org/scastie/sbt/SbtProcess.scala b/sbt-runner/src/main/scala/org/scastie/sbt/SbtProcess.scala index 0e4e68293..abccf9b72 100644 --- a/sbt-runner/src/main/scala/org/scastie/sbt/SbtProcess.scala +++ b/sbt-runner/src/main/scala/org/scastie/sbt/SbtProcess.scala @@ -2,18 +2,17 @@ package org.scastie.sbt import java.nio.file._ import java.time.Instant +import scala.concurrent.duration._ +import scala.util.Random import akka.actor.{ActorRef, Cancellable, FSM, Stash} import akka.pattern.ask import akka.util.Timeout import org.scastie.api._ import org.scastie.instrumentation.InstrumentedInputs -import org.scastie.util.ScastieFileUtil.{slurp, write} -import org.scastie.util._ import org.scastie.instrumentation.LineMapper - -import scala.concurrent.duration._ -import scala.util.Random +import org.scastie.util._ +import org.scastie.util.ScastieFileUtil.{slurp, write} object SbtProcess { sealed trait SbtState @@ -24,6 +23,7 @@ object SbtProcess { sealed trait Data case class SbtData(currentInputs: SbtInputs) extends Data + case class SbtRun( snippetId: SnippetId, inputs: SbtInputs, @@ -33,14 +33,15 @@ object SbtProcess { timeoutEvent: Option[Cancellable], lineMapping: Int => Int = identity ) extends Data + case class SbtStateTimeout(duration: FiniteDuration, state: SbtState) { + def message: String = { - val stateMsg = - state match { - case Reloading => "updating build configuration" - case Running => "running code" - case _ => sys.error(s"unexpected timeout in state $state") - } + val stateMsg = state match { + case Reloading => "updating build configuration" + case Running => "running code" + case _ => sys.error(s"unexpected timeout in state $state") + } s"timed out after $duration when $stateMsg" } @@ -60,19 +61,22 @@ object SbtProcess { ) ) } + } + } -class SbtProcess(runTimeout: FiniteDuration, - reloadTimeout: FiniteDuration, - isProduction: Boolean, - javaOptions: Seq[String], - customSbtDir: Option[Path] = None) - extends FSM[SbtProcess.SbtState, SbtProcess.Data] - with Stash { +class SbtProcess( + runTimeout: FiniteDuration, + reloadTimeout: FiniteDuration, + isProduction: Boolean, + javaOptions: Seq[String], + customSbtDir: Option[Path] = None +) extends FSM[SbtProcess.SbtState, SbtProcess.Data] + with Stash { + import context.dispatcher import ProcessActor._ import SbtProcess._ - import context.dispatcher private var progressId = 0L @@ -82,14 +86,12 @@ class SbtProcess(runTimeout: FiniteDuration, run.progressActor ! p implicit val tm = Timeout(10.seconds) (run.snippetActor ? p) - .recover { - case e => - log.error(e, s"error while saving progress $p") + .recover { case e => + log.error(e, s"error while saving progress $p") } } - private val sbtDir: Path = - customSbtDir.getOrElse(Files.createTempDirectory("scastie")) + private val sbtDir: Path = customSbtDir.getOrElse(Files.createTempDirectory("scastie")) private val buildFile = sbtDir.resolve("build.sbt") private val promptUniqueId = Random.alphanumeric.take(10).mkString @@ -125,21 +127,19 @@ class SbtProcess(runTimeout: FiniteDuration, ) private lazy val process = { - val sbtOpts = - (javaOptions ++ Seq( - "-Djline.terminal=jline.UnsupportedTerminal", - "-Dsbt.log.noformat=true", - "-Dsbt.banner=false", - )).mkString(" ") - - val props = - ProcessActor.props( - command = List("sbt"), - workingDir = sbtDir, - environment = Map( - "SBT_OPTS" -> sbtOpts - ) + val sbtOpts = (javaOptions ++ Seq( + "-Djline.terminal=jline.UnsupportedTerminal", + "-Dsbt.log.noformat=true", + "-Dsbt.banner=false" + )).mkString(" ") + + val props = ProcessActor.props( + command = List("sbt"), + workingDir = sbtDir, + environment = Map( + "SBT_OPTS" -> sbtOpts ) + ) context.actorOf(props, name = s"sbt-process-$promptUniqueId") } @@ -160,65 +160,63 @@ class SbtProcess(runTimeout: FiniteDuration, case _ -> Ready => println("-- Ready --") unstashAll() - case _ -> Initializing => - println("-- Initializing --") - case _ -> Reloading => - println("-- Reloading --") - case _ -> Running => - println("-- Running --") + case _ -> Initializing => println("-- Initializing --") + case _ -> Reloading => println("-- Reloading --") + case _ -> Running => println("-- Running --") } - when(Initializing) { - case Event(out: ProcessOutput, _) => - if (isPrompt(out.line)) { - goto(Ready) - } else { - stay() - } + when(Initializing) { case Event(out: ProcessOutput, _) => + if (isPrompt(out.line)) { + goto(Ready) + } else { + stay() + } } - when(Ready) { - case Event(task @ SbtTask(snippetId, taskInputs, ip, login, progressActor), SbtData(stateInputs)) => - println(s"Running: (login: $login, ip: $ip) \n ${taskInputs.code.take(30)}") - - val _sbtRun = SbtRun( - snippetId = snippetId, - inputs = taskInputs, - isForcedProgramMode = false, - progressActor = progressActor, - snippetActor = sender(), - timeoutEvent = None - ) - sendProgress(_sbtRun, SnippetProgress.default.copy(isDone = false, ts = Some(Instant.now.toEpochMilli), snippetId = Some(snippetId))) + when(Ready) { case Event(task @ SbtTask(snippetId, taskInputs, ip, login, progressActor), SbtData(stateInputs)) => + println(s"Running: (login: $login, ip: $ip) \n ${taskInputs.code.take(30)}") + + val _sbtRun = SbtRun( + snippetId = snippetId, + inputs = taskInputs, + isForcedProgramMode = false, + progressActor = progressActor, + snippetActor = sender(), + timeoutEvent = None + ) + sendProgress( + _sbtRun, + SnippetProgress.default.copy(isDone = false, ts = Some(Instant.now.toEpochMilli), snippetId = Some(snippetId)) + ) + + InstrumentedInputs(taskInputs) match { + case Right(instrumented) => + val sbtRun = _sbtRun.copy( + inputs = instrumented.inputs.asInstanceOf[SbtInputs], + isForcedProgramMode = instrumented.isForcedProgramMode, + lineMapping = instrumented.lineMapping + ) + val isReloading = stateInputs.needsReload(sbtRun.inputs) + setInputs(sbtRun.inputs) - InstrumentedInputs(taskInputs) match { - case Right(instrumented) => - val sbtRun = _sbtRun.copy( - inputs = instrumented.inputs.asInstanceOf[SbtInputs], - isForcedProgramMode = instrumented.isForcedProgramMode, - lineMapping = instrumented.lineMapping - ) - val isReloading = stateInputs.needsReload(sbtRun.inputs) - setInputs(sbtRun.inputs) - - instrumented.optionalParsingError.foreach { error => - sendProgress(sbtRun, error.toProgress(snippetId).copy(isDone = false)) - } - - if (isReloading) { - process ! Input("reload;compile/compileInputs") - gotoWithTimeout(sbtRun, Reloading, reloadTimeout) - } else { - gotoRunning(sbtRun) - } - - case Left(report) => - log.info(s"Instrumentation error: ${report.message}") - val sbtRun = _sbtRun - setInputs(sbtRun.inputs) - sendProgress(sbtRun, report.toProgress(snippetId)) - goto(Ready) - } + instrumented.optionalParsingError.foreach { error => + sendProgress(sbtRun, error.toProgress(snippetId).copy(isDone = false)) + } + + if (isReloading) { + process ! Input("reload;compile/compileInputs") + gotoWithTimeout(sbtRun, Reloading, reloadTimeout) + } else { + gotoRunning(sbtRun) + } + + case Left(report) => + log.info(s"Instrumentation error: ${report.message}") + val sbtRun = _sbtRun + setInputs(sbtRun.inputs) + sendProgress(sbtRun, report.toProgress(snippetId)) + goto(Ready) + } } val extractor = new OutputExtractor( @@ -228,45 +226,42 @@ class SbtProcess(runTimeout: FiniteDuration, promptUniqueId ) - when(Reloading) { - case Event(output: ProcessOutput, sbtRun: SbtRun) => - val progress = extractor.extractProgress(output, sbtRun, isReloading = true) - sendProgress(sbtRun, progress) + when(Reloading) { case Event(output: ProcessOutput, sbtRun: SbtRun) => + val progress = extractor.extractProgress(output, sbtRun, isReloading = true) + sendProgress(sbtRun, progress) - if (progress.isSbtError) { - throw new Exception("sbt error: " + output.line) - } + if (progress.isSbtError) { + throw new Exception("sbt error: " + output.line) + } - if (isPrompt(output.line)) { - gotoRunning(sbtRun) - } else { - stay() - } + if (isPrompt(output.line)) { + gotoRunning(sbtRun) + } else { + stay() + } } - when(Running) { - case Event(output: ProcessOutput, sbtRun: SbtRun) => - val progress = extractor.extractProgress(output, sbtRun, isReloading = false) - sendProgress(sbtRun, progress) + when(Running) { case Event(output: ProcessOutput, sbtRun: SbtRun) => + val progress = extractor.extractProgress(output, sbtRun, isReloading = false) + sendProgress(sbtRun, progress) - if (progress.isDone) { - sbtRun.timeoutEvent.foreach(_.cancel()) - goto(Ready).using(SbtData(sbtRun.inputs)) - } else { - stay() - } + if (progress.isDone) { + sbtRun.timeoutEvent.foreach(_.cancel()) + goto(Ready).using(SbtData(sbtRun.inputs)) + } else { + stay() + } } private def gotoWithTimeout(sbtRun: SbtRun, nextState: SbtState, duration: FiniteDuration): this.State = { sbtRun.timeoutEvent.foreach(_.cancel()) - val timeout = - context.system.scheduler.scheduleOnce( - duration, - self, - SbtStateTimeout(duration, nextState) - ) + val timeout = context.system.scheduler.scheduleOnce( + duration, + self, + SbtStateTimeout(duration, nextState) + ) goto(nextState).using(sbtRun.copy(timeoutEvent = Some(timeout))) } @@ -283,8 +278,7 @@ class SbtProcess(runTimeout: FiniteDuration, // Sbt files setup private def setInputs(inputs: SbtInputs): Unit = { - val prompt = - s"""shellPrompt := {_ => println(""); "$promptUniqueId" + "\\n "}""" + val prompt = s"""shellPrompt := {_ => println(""); "$promptUniqueId" + "\\n "}""" writeFile(pluginFile, inputs.sbtPluginsConfig + "\n") writeFile(buildFile, prompt + "\n" + inputs.sbtConfig) diff --git a/sbt-runner/src/test/scala/org/scastie/sbt/FormatActorTest.scala b/sbt-runner/src/test/scala/org/scastie/sbt/FormatActorTest.scala index 650e69d32..23556e77d 100644 --- a/sbt-runner/src/test/scala/org/scastie/sbt/FormatActorTest.scala +++ b/sbt-runner/src/test/scala/org/scastie/sbt/FormatActorTest.scala @@ -1,9 +1,9 @@ package org.scastie.sbt +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.Assertions._ import org.scastie.api._ import org.scastie.sbt.FormatActor -import org.scalatest.Assertions._ -import org.scalatest.funsuite.AnyFunSuite class FormatActorTest extends AnyFunSuite { test("format should accept scala 2 code") { diff --git a/sbt-runner/src/test/scala/org/scastie/sbt/SbtActorTest.scala b/sbt-runner/src/test/scala/org/scastie/sbt/SbtActorTest.scala index 27604355c..11f4373b2 100644 --- a/sbt-runner/src/test/scala/org/scastie/sbt/SbtActorTest.scala +++ b/sbt-runner/src/test/scala/org/scastie/sbt/SbtActorTest.scala @@ -1,17 +1,21 @@ package org.scastie.sbt +import scala.concurrent.duration._ + import akka.actor.{ActorRef, ActorSystem, Props} -import akka.testkit.TestActor.AutoPilot import akka.testkit.{ImplicitSender, TestKit, TestProbe} -import org.scastie.runtime.api._ +import akka.testkit.TestActor.AutoPilot +import org.scalatest.funsuite.AnyFunSuiteLike +import org.scalatest.BeforeAndAfterAll import org.scastie.api._ +import org.scastie.runtime.api._ import org.scastie.util.SbtTask -import org.scalatest.BeforeAndAfterAll -import org.scalatest.funsuite.AnyFunSuiteLike - -import scala.concurrent.duration._ -class SbtActorTest() extends TestKit(ActorSystem("SbtActorTest")) with ImplicitSender with AnyFunSuiteLike with BeforeAndAfterAll { +class SbtActorTest() + extends TestKit(ActorSystem("SbtActorTest")) + with ImplicitSender + with AnyFunSuiteLike + with BeforeAndAfterAll { setAutoPilot(new AutoPilot { def run(sender: ActorRef, msg: Any): AutoPilot = { sender ! s"reply to $msg" @@ -77,7 +81,7 @@ class SbtActorTest() extends TestKit(ActorSystem("SbtActorTest")) with ImplicitS val message = "Hello" runCode( s"""object Main { def main(args: Array[String]): Unit = println("$message") }""", - allowFailure = true, + allowFailure = true ) { progress => if (progress.isDone) progress.isForcedProgramMode else false } @@ -116,15 +120,13 @@ class SbtActorTest() extends TestKit(ActorSystem("SbtActorTest")) with ImplicitS } test("Scala.js support") { - val scalaJs = - SbtInputs.default.copy(code = "1 + 1", target = Js.default) + val scalaJs = SbtInputs.default.copy(code = "1 + 1", target = Js.default) run(scalaJs)(_.isDone) } test("Scala.js 3 support") { - val scalaJs = - SbtInputs.default.copy(code = "1 + 1", - target = Js.default.copy(scalaVersion = org.scastie.buildinfo.BuildInfo.latestLTS)) + val scalaJs = SbtInputs.default + .copy(code = "1 + 1", target = Js.default.copy(scalaVersion = org.scastie.buildinfo.BuildInfo.latestLTS)) run(scalaJs)(_.isDone) } @@ -147,9 +149,10 @@ class SbtActorTest() extends TestKit(ActorSystem("SbtActorTest")) with ImplicitS } test("avoid https://github.com/scala/bug/issues/8119") { - val scala = - SbtInputs.default.copy(code = "val n = 0; val m = List(1).par.foreach(_ => n); println(1)", - target = Scala2(org.scastie.buildinfo.BuildInfo.latest212)) + val scala = SbtInputs.default.copy( + code = "val n = 0; val m = List(1).par.foreach(_ => n); println(1)", + target = Scala2(org.scastie.buildinfo.BuildInfo.latest212) + ) run(scala)(assertUserOutput("1")) } @@ -166,10 +169,11 @@ class SbtActorTest() extends TestKit(ActorSystem("SbtActorTest")) with ImplicitS } test("no warnings on 2.12") { - val scala = - SbtInputs.default.copy(code = "println(1 + 1)", - sbtConfigExtra = """scalacOptions ++= List("-Xlint", "-Xfatal-warnings")""", - target = Scala2("2.12.10")) + val scala = SbtInputs.default.copy( + code = "println(1 + 1)", + sbtConfigExtra = """scalacOptions ++= List("-Xlint", "-Xfatal-warnings")""", + target = Scala2("2.12.10") + ) run(scala)(assertUserOutput("2")) } @@ -192,7 +196,7 @@ class SbtActorTest() extends TestKit(ActorSystem("SbtActorTest")) with ImplicitS val message = "Hello, Scala 3 worksheet!" val dotty = SbtInputs.default.copy( code = s"""println("$message")""", - target = Scala3.default, + target = Scala3.default ) run(dotty)(assertUserOutput("Hello, Scala 3 worksheet!")) } @@ -201,7 +205,7 @@ class SbtActorTest() extends TestKit(ActorSystem("SbtActorTest")) with ImplicitS val message = "Hello, Scala 3.0 worksheet!" val dotty = SbtInputs.default.copy( code = s"""println("$message")""", - target = Scala3("3.0.0"), + target = Scala3("3.0.0") ) run(dotty)(assertUserOutput("Hello, Scala 3.0 worksheet!")) } @@ -221,7 +225,7 @@ class SbtActorTest() extends TestKit(ActorSystem("SbtActorTest")) with ImplicitS val dotty = SbtInputs.default.copy( code = s"""|println("Hello world!") |// test comment""".stripMargin, - target = Scala2.default, + target = Scala2.default ) run(dotty)(assertUserOutput("Hello world!")) } @@ -230,7 +234,7 @@ class SbtActorTest() extends TestKit(ActorSystem("SbtActorTest")) with ImplicitS val dotty = SbtInputs.default.copy( code = s"""|println("Hello world!") |// test comment""".stripMargin, - target = Scala3.default, + target = Scala3.default ) run(dotty)(assertUserOutput("Hello world!")) } @@ -239,7 +243,7 @@ class SbtActorTest() extends TestKit(ActorSystem("SbtActorTest")) with ImplicitS val dotty = SbtInputs.default.copy( code = s"""|println: | "Hello world!" - |""".stripMargin, + |""".stripMargin ) var outputOk = false var instrOk = false @@ -336,7 +340,7 @@ class SbtActorTest() extends TestKit(ActorSystem("SbtActorTest")) with ImplicitS code = """|if true then | if true then | println("yes") - |""".stripMargin, + |""".stripMargin ) var outputOk = false var instrOk = false @@ -351,7 +355,7 @@ class SbtActorTest() extends TestKit(ActorSystem("SbtActorTest")) with ImplicitS val dotty = SbtInputs.default.copy( code = """|List(1,2,3).map: | case x => x - |""".stripMargin, + |""".stripMargin ) run(dotty) { progress => assertInstrumentation( @@ -368,7 +372,7 @@ class SbtActorTest() extends TestKit(ActorSystem("SbtActorTest")) with ImplicitS | | |1 + 5 - |""".stripMargin, + |""".stripMargin ) run(dotty) { progress => assertInstrumentation( @@ -383,7 +387,9 @@ class SbtActorTest() extends TestKit(ActorSystem("SbtActorTest")) with ImplicitS } test("hide Playground from types") { - runCode("case class A(i:Int) extends AnyVal; A(1)")(_.instrumentations.headOption.exists(_.render == Value("A(1)", "A"))) + runCode("case class A(i:Int) extends AnyVal; A(1)")( + _.instrumentations.headOption.exists(_.render == Value("A(1)", "A")) + ) } test("#304 null pointer") { @@ -437,7 +443,7 @@ class SbtActorTest() extends TestKit(ActorSystem("SbtActorTest")) with ImplicitS } def assertCompilationInfo( - infoAssert: Problem => Any + infoAssert: Problem => Any )(progress: SnippetProgress): Boolean = { val gotCompilationError = progress.compilationInfos.nonEmpty @@ -470,12 +476,15 @@ class SbtActorTest() extends TestKit(ActorSystem("SbtActorTest")) with ImplicitS ) private var currentId = 0 + private def snippetId = { val t = currentId currentId += 1 SnippetId(t.toString, None) } + private var firstRun = true + private def run(inputs: SbtInputs, allowFailure: Boolean = false)(fish: SnippetProgress => Boolean): Unit = { val ip = "my-ip" val progressActor = TestProbe() @@ -486,19 +495,18 @@ class SbtActorTest() extends TestKit(ActorSystem("SbtActorTest")) with ImplicitS if (firstRun) timeout + 10.second else timeout - progressActor.fishForMessage(totalTimeout + 100.seconds) { - case progress: SnippetProgress => - val fishResult = fish(progress) - // println(progress -> fishResult) - if ((progress.isFailure && !allowFailure) || (progress.isDone && !fishResult)) - throw new Exception(s"Fail to meet expectation at ${progress}") - else fishResult + progressActor.fishForMessage(totalTimeout + 100.seconds) { case progress: SnippetProgress => + val fishResult = fish(progress) + // println(progress -> fishResult) + if ((progress.isFailure && !allowFailure) || (progress.isDone && !fishResult)) + throw new Exception(s"Fail to meet expectation at ${progress}") + else fishResult } firstRun = false } private def runCode(code: String, target: SbtScalaTarget = Scala2.default, allowFailure: Boolean = false)( - fish: SnippetProgress => Boolean + fish: SnippetProgress => Boolean ): Unit = { run(SbtInputs.default.copy(code = code, target = target), allowFailure)(fish) } @@ -507,14 +515,12 @@ class SbtActorTest() extends TestKit(ActorSystem("SbtActorTest")) with ImplicitS expected: Value, position: Position )(progress: SnippetProgress): Boolean = { - progress.instrumentations.exists(instr => - instr.render == expected && instr.position == position - ) + progress.instrumentations.exists(instr => instr.render == expected && instr.position == position) } private def assertUserOutput( - message: String, - outputType: ProcessOutputType = ProcessOutputType.StdOut + message: String, + outputType: ProcessOutputType = ProcessOutputType.StdOut )(progress: SnippetProgress): Boolean = { val gotHelloMessage = progress.userOutput.exists(out => out.line == message && out.tpe == outputType) // if (!gotHelloMessage) assert(progress.userOutput.isEmpty) diff --git a/sbt-scastie/src/main/scala/org/scastie/sbt/plugin/CompilerReporter.scala b/sbt-scastie/src/main/scala/org/scastie/sbt/plugin/CompilerReporter.scala index 545593c7d..aad654e66 100644 --- a/sbt-scastie/src/main/scala/org/scastie/sbt/plugin/CompilerReporter.scala +++ b/sbt-scastie/src/main/scala/org/scastie/sbt/plugin/CompilerReporter.scala @@ -1,20 +1,18 @@ package org.scastie.sbtplugin -import io.circe.syntax._ +import java.util.Optional +import io.circe.syntax._ import org.scastie.api - +import org.scastie.api.{Error, Info, Warning} import sbt._ -import Keys._ +import xsbti.{Position, Problem, Reporter, Severity} import KeyRanks.DTask - +import Keys._ import System.{lineSeparator => nl} -import xsbti.{Reporter, Problem, Position, Severity} -import java.util.Optional -import org.scastie.api.{Info, Error, Warning} - object CompilerReporter { + // compilerReporter is marked private in sbt private lazy val compilerReporter = TaskKey[xsbti.Reporter]( "compilerReporter", @@ -22,44 +20,43 @@ object CompilerReporter { DTask ) - val setting: sbt.Def.Setting[_] = - Compile / compile / compilerReporter := new xsbti.Reporter { - private val buffer = collection.mutable.ArrayBuffer.empty[Problem] - def reset(): Unit = buffer.clear() - def hasErrors: Boolean = buffer.exists(_.severity == Severity.Error) - def hasWarnings: Boolean = buffer.exists(_.severity == Severity.Warn) - - def printSummary(): Unit = { - def toApi(p: Problem): api.Problem = { - def toOption[T](m: Optional[T]): Option[T] = { - if (!m.isPresent) None - else Some(m.get) - } - val severity = - p.severity match { - case xsbti.Severity.Info => api.Info - case xsbti.Severity.Warn => api.Warning - case xsbti.Severity.Error => api.Error - } - api.Problem(severity, toOption(p.position.line).map(_.toInt), p.message) + val setting: sbt.Def.Setting[_] = Compile / compile / compilerReporter := new xsbti.Reporter { + private val buffer = collection.mutable.ArrayBuffer.empty[Problem] + def reset(): Unit = buffer.clear() + def hasErrors: Boolean = buffer.exists(_.severity == Severity.Error) + def hasWarnings: Boolean = buffer.exists(_.severity == Severity.Warn) + + def printSummary(): Unit = { + def toApi(p: Problem): api.Problem = { + def toOption[T](m: Optional[T]): Option[T] = { + if (!m.isPresent) None + else Some(m.get) } - if (problems.nonEmpty) { - val apiProblems = problems.map(toApi) - println(apiProblems.asJson.noSpaces) + val severity = p.severity match { + case xsbti.Severity.Info => api.Info + case xsbti.Severity.Warn => api.Warning + case xsbti.Severity.Error => api.Error } + api.Problem(severity, toOption(p.position.line).map(_.toInt), p.message) + } + if (problems.nonEmpty) { + val apiProblems = problems.map(toApi) + println(apiProblems.asJson.noSpaces) } - def problems: Array[Problem] = buffer.toArray + } + def problems: Array[Problem] = buffer.toArray // def log(pos: Position, msg: String, sev: Severity): Unit = { - def log(problem: Problem): Unit = { - object MyProblem extends Problem { - def category: String = "foo" - def severity: Severity = problem.severity() - def message: String = problem.message() - def position: Position = problem.position() - override def toString = s"$position:$severity: $message" - } - buffer.append(MyProblem) + def log(problem: Problem): Unit = { + object MyProblem extends Problem { + def category: String = "foo" + def severity: Severity = problem.severity() + def message: String = problem.message() + def position: Position = problem.position() + override def toString = s"$position:$severity: $message" } - def comment(pos: xsbti.Position, msg: String): Unit = () + buffer.append(MyProblem) } + def comment(pos: xsbti.Position, msg: String): Unit = () + } + } diff --git a/sbt-scastie/src/main/scala/org/scastie/sbt/plugin/RuntimeErrorLogger.scala b/sbt-scastie/src/main/scala/org/scastie/sbt/plugin/RuntimeErrorLogger.scala index e0cd2af45..b0a8017f4 100644 --- a/sbt-scastie/src/main/scala/org/scastie/sbt/plugin/RuntimeErrorLogger.scala +++ b/sbt-scastie/src/main/scala/org/scastie/sbt/plugin/RuntimeErrorLogger.scala @@ -1,23 +1,23 @@ package sbt.internal.util.org.scastie.sbtplugin +import java.io.{OutputStream, PrintWriter} +import java.nio.channels.ClosedChannelException +import java.util.concurrent.atomic.AtomicReference + import io.circe._ import io.circe.syntax._ -import org.scastie.api._ import org.apache.logging.log4j.core.{Appender => XAppender, LogEvent => XLogEvent} import org.apache.logging.log4j.message.ObjectMessage -import sbt.Keys._ +import org.scastie.api._ +import org.scastie.runtime.api.{Instrumentation, RuntimeError, RuntimeErrorWrap} import sbt._ -import sbt.internal.util.ConsoleAppender.Properties import sbt.internal.util.{ConsoleAppender, Log4JConsoleAppender, ObjectEvent, TraceEvent} - -import java.io.{OutputStream, PrintWriter} -import java.nio.channels.ClosedChannelException -import java.util.concurrent.atomic.AtomicReference -import org.scastie.api._ -import org.scastie.runtime.api.{Instrumentation, RuntimeErrorWrap, RuntimeError} +import sbt.internal.util.ConsoleAppender.Properties +import sbt.Keys._ import RuntimeCodecs._ object RuntimeErrorLogger { + private val scastieOut = new PrintWriter(new OutputStream { def out(in: String): Unit = { val consoleOutput: ConsoleOutput = SbtOutput(ProcessOutput(in.trim, ProcessOutputType.StdOut, None)) @@ -31,29 +31,29 @@ object RuntimeErrorLogger { }) private def findThrowable(event: XLogEvent) = { - //daaamn + // daaamn Option(event.getThrown).orElse { for { - e <- Option(event.getMessage).collect { - case e: ObjectMessage => e + e <- Option(event.getMessage).collect { case e: ObjectMessage => + e } - e <- Option(e.getParameter).collect { - case e: ObjectEvent[_] => e + e <- Option(e.getParameter).collect { case e: ObjectEvent[_] => + e } - e <- Option(e.message).collect { - case e: TraceEvent => e + e <- Option(e.message).collect { case e: TraceEvent => + e } - //since worksheet wraps the code in object we unwrap it to display clearer message + // since worksheet wraps the code in object we unwrap it to display clearer message e <- Option(e.message).collect { case e: ExceptionInInitializerError if e.getCause != null && e.getCause.getStackTrace.headOption.exists { e => e.getClassName == Instrumentation.instrumentedObject + "$" && e.getMethodName == "" - } => - e.getCause + } => e.getCause case e => e } } yield e } } + private def logThrowable(throwable: Throwable): Unit = { val error = RuntimeErrorWrap(RuntimeError.fromThrowable(throwable)) println(error.asJson.noSpaces) @@ -62,34 +62,38 @@ object RuntimeErrorLogger { val settings: Seq[sbt.Def.Setting[_]] = Seq( showSuccess := false, useLog4J := true, - logManager := sbt.internal.LogManager.withLoggers( - (_, _) => - new ConsoleAppender(ConsoleAppender.generateName, Properties.from(ConsoleOut.printWriterOut(scastieOut), true, false), _ => None) { - override def trace(t: => Throwable, traceLevel: Int): Unit = logThrowable(t) - private[this] val log4j = new AtomicReference[XAppender](null) - private[sbt] override lazy val toLog4J = log4j.get match { - case null => - log4j.synchronized { - log4j.get match { - case null => - val l = new Log4JConsoleAppender( - name, - properties, - suppressedMessage, { event => - val level = ConsoleAppender.toLevel(event.getLevel) - val message = event.getMessage - findThrowable(event).foreach(logThrowable) - try appendMessage(level, message) - catch { case _: ClosedChannelException => } - } - ) - log4j.set(l) - l - case l => l - } + logManager := sbt.internal.LogManager.withLoggers((_, _) => + new ConsoleAppender( + ConsoleAppender.generateName, + Properties.from(ConsoleOut.printWriterOut(scastieOut), true, false), + _ => None + ) { + override def trace(t: => Throwable, traceLevel: Int): Unit = logThrowable(t) + private[this] val log4j = new AtomicReference[XAppender](null) + private[sbt] override lazy val toLog4J = log4j.get match { + case null => log4j.synchronized { + log4j.get match { + case null => + val l = new Log4JConsoleAppender( + name, + properties, + suppressedMessage, + { event => + val level = ConsoleAppender.toLevel(event.getLevel) + val message = event.getMessage + findThrowable(event).foreach(logThrowable) + try appendMessage(level, message) + catch { case _: ClosedChannelException => } + } + ) + log4j.set(l) + l + case l => l } - } + } + } } - ), + ) ) + } diff --git a/sbt-scastie/src/main/scala/org/scastie/sbt/plugin/SbtScastiePlugin.scala b/sbt-scastie/src/main/scala/org/scastie/sbt/plugin/SbtScastiePlugin.scala index 2905d7ef3..030735869 100644 --- a/sbt-scastie/src/main/scala/org/scastie/sbt/plugin/SbtScastiePlugin.scala +++ b/sbt-scastie/src/main/scala/org/scastie/sbt/plugin/SbtScastiePlugin.scala @@ -1,10 +1,10 @@ package org.scastie.sbtplugin -import sbt.Keys.* +import scala.util.{Failure, Success, Try} + import sbt.* import sbt.internal.inc.AnalyzingCompiler - -import scala.util.{Success, Try, Failure} +import sbt.Keys.* object SbtScastiePlugin extends AutoPlugin { @@ -14,18 +14,17 @@ object SbtScastiePlugin extends AutoPlugin { override lazy val projectSettings: Seq[sbt.Def.Setting[_]] = (CompilerReporter.setting +: sbt.internal.util.org.scastie.sbtplugin.RuntimeErrorLogger.settings) ++ Seq( - //workaround https://github.com/sbt/sbt/issues/5482 + // workaround https://github.com/sbt/sbt/issues/5482 Global / nio.Keys.onChangedBuildSource := nio.Keys.IgnoreSourceChanges, turbo := true, useSuperShell := false, autoStartServer := false, compilers := { val r = compilers.value - //compile bridge to init everything on reload + // compile bridge to init everything on reload r.scalac() match { - case c: AnalyzingCompiler => - c.provider.fetchCompiledBridge(c.scalaInstance, streams.value.log) - case _ => () + case c: AnalyzingCompiler => c.provider.fetchCompiledBridge(c.scalaInstance, streams.value.log) + case _ => () } r }, @@ -48,10 +47,13 @@ object SbtScastiePlugin extends AutoPlugin { resolvers := { Seq[Resolver]( Resolver - .url("my-ivy-proxy-releases", url("http://scala-webapps.epfl.ch:8081/artifactory/scastie-ivy/"))(Resolver.ivyStylePatterns) + .url("my-ivy-proxy-releases", url("http://scala-webapps.epfl.ch:8081/artifactory/scastie-ivy/"))( + Resolver.ivyStylePatterns + ) .withAllowInsecureProtocol(true), - "my-maven-proxy-releases" at "http://scala-webapps.epfl.ch:8081/artifactory/scastie-maven/" withAllowInsecureProtocol (true), + "my-maven-proxy-releases" at "http://scala-webapps.epfl.ch:8081/artifactory/scastie-maven/" withAllowInsecureProtocol (true) ) ++ resolvers.value - }, + } ) + } diff --git a/sbt-scastie/src/main/scala/sbt/ScastieTrapExit.scala b/sbt-scastie/src/main/scala/sbt/ScastieTrapExit.scala index bdd61508a..ad3883c95 100644 --- a/sbt-scastie/src/main/scala/sbt/ScastieTrapExit.scala +++ b/sbt-scastie/src/main/scala/sbt/ScastieTrapExit.scala @@ -7,56 +7,52 @@ package sbt -import scala.annotation.nowarn -import scala.reflect.Manifest -import scala.collection.concurrent.TrieMap import java.lang.ref.WeakReference -import Thread.currentThread +import java.lang.Integer.{toHexString => hex} import java.security.Permission import java.util.concurrent.{ConcurrentHashMap => CMap} -import java.lang.Integer.{toHexString => hex} import java.util.function.Supplier +import scala.annotation.nowarn +import scala.collection.concurrent.TrieMap +import scala.reflect.Manifest import sbt.util.InterfaceUtil import ScastieTrapExit._ +import Thread.currentThread /** - * Provides an approximation to isolated execution within a single JVM. - * System.exit calls are trapped to prevent the JVM from terminating. This is useful for executing - * user code that may call System.exit, but actually exiting is undesirable. - * - * Exit is simulated by disposing all top-level windows and interrupting user-started threads. - * Threads are not stopped and shutdown hooks are not called. It is - * therefore inappropriate to use this with code that requires shutdown hooks, creates threads that - * do not terminate, or if concurrent AWT applications are run. - * This category of code should only be called by forking a new JVM. - */ + * Provides an approximation to isolated execution within a single JVM. System.exit calls are trapped to prevent the + * JVM from terminating. This is useful for executing user code that may call System.exit, but actually exiting is + * undesirable. + * + * Exit is simulated by disposing all top-level windows and interrupting user-started threads. Threads are not stopped + * and shutdown hooks are not called. It is therefore inappropriate to use this with code that requires shutdown hooks, + * creates threads that do not terminate, or if concurrent AWT applications are run. This category of code should only + * be called by forking a new JVM. + */ @nowarn object ScastieTrapExit { /** - * Run `execute` in a managed context, using `log` for debugging messages. - * `installManager` must be called before calling this method. - */ - def apply(execute: => Unit, log: Logger): Int = - System.getSecurityManager match { - case m: ScastieTrapExit => m.runManaged(InterfaceUtil.toSupplier(execute), log) - case _ => runUnmanaged(execute, log) - } + * Run `execute` in a managed context, using `log` for debugging messages. `installManager` must be called before + * calling this method. + */ + def apply(execute: => Unit, log: Logger): Int = System.getSecurityManager match { + case m: ScastieTrapExit => m.runManaged(InterfaceUtil.toSupplier(execute), log) + case _ => runUnmanaged(execute, log) + } /** - * Installs the SecurityManager that implements the isolation and returns the previously installed SecurityManager, which may be null. - * This method must be called before using `apply`. - */ - def installManager(): SecurityManager = - System.getSecurityManager match { - case m: ScastieTrapExit => m - case m => System.setSecurityManager(new ScastieTrapExit(m)); m - } + * Installs the SecurityManager that implements the isolation and returns the previously installed SecurityManager, + * which may be null. This method must be called before using `apply`. + */ + def installManager(): SecurityManager = System.getSecurityManager match { + case m: ScastieTrapExit => m + case m => System.setSecurityManager(new ScastieTrapExit(m)); m + } /** Uninstalls the isolation SecurityManager and restores the old security manager. */ - def uninstallManager(previous: SecurityManager): Unit = - System.setSecurityManager(previous) + def uninstallManager(previous: SecurityManager): Unit = System.setSecurityManager(previous) private[this] def runUnmanaged(execute: => Unit, log: Logger): Int = { log.warn("Managed execution not possible: security manager not installed.") @@ -72,11 +68,10 @@ object ScastieTrapExit { private type ThreadID = String - /** `true` if the thread `t` is in the TERMINATED state.x*/ + /** `true` if the thread `t` is in the TERMINATED state.x */ private def isDone(t: Thread): Boolean = t.getState == Thread.State.TERMINATED - private def computeID(g: ThreadGroup): ThreadID = - s"g:${hex(System.identityHashCode(g))}:${g.getName}" + private def computeID(g: ThreadGroup): ThreadID = s"g:${hex(System.identityHashCode(g))}:${g.getName}" /** Computes an identifier for a Thread that has a high probability of being unique within a single JVM execution. */ private def computeID(t: Thread): ThreadID = @@ -85,7 +80,9 @@ object ScastieTrapExit { // Apple AWT: +[ThreadUtilities getJNIEnvUncached] attempting to attach current thread after JNFObtainEnv() failed s"${hex(System.identityHashCode(t))}" - /** Waits for the given `thread` to terminate. However, if the thread state is NEW, this method returns immediately. */ + /** + * Waits for the given `thread` to terminate. However, if the thread state is NEW, this method returns immediately. + */ private def waitOnThread(thread: Thread, log: Logger): Unit = { log.debug("Waiting for thread " + thread.getName + " to terminate.") thread.join @@ -99,8 +96,11 @@ object ScastieTrapExit { thread.interrupt log.debug("\tInterrupted " + thread.getName) } + // an uncaught exception handler that swallows InterruptedExceptions and otherwise defers to originalHandler - private final class TrapInterrupt(originalHandler: Thread.UncaughtExceptionHandler) extends Thread.UncaughtExceptionHandler { + private final class TrapInterrupt(originalHandler: Thread.UncaughtExceptionHandler) + extends Thread.UncaughtExceptionHandler { + def uncaughtException(thread: Thread, e: Throwable): Unit = { withCause[InterruptedException, Unit](e) { interrupted => () @@ -109,47 +109,50 @@ object ScastieTrapExit { } thread.setUncaughtExceptionHandler(originalHandler) } + } /** - * Recurses into the causes of the given exception looking for a cause of type CauseType. If one is found, `withType` is called with that cause. - * If not, `notType` is called with the root cause. - */ + * Recurses into the causes of the given exception looking for a cause of type CauseType. If one is found, `withType` + * is called with that cause. If not, `notType` is called with the root cause. + */ private def withCause[CauseType <: Throwable, T]( - e: Throwable - )(withType: CauseType => T)(notType: Throwable => T)(implicit mf: Manifest[CauseType]): T = { + e: Throwable + )(withType: CauseType => T)(notType: Throwable => T)( + implicit mf: Manifest[CauseType] + ): T = { val clazz = mf.runtimeClass - if (clazz.isInstance(e)) - withType(e.asInstanceOf[CauseType]) + if (clazz.isInstance(e)) withType(e.asInstanceOf[CauseType]) else { val cause = e.getCause - if (cause == null) - notType(e) - else - withCause(cause)(withType)(notType)(mf) + if (cause == null) notType(e) + else withCause(cause)(withType)(notType)(mf) } } } /** - * Simulates isolation via a SecurityManager. - * Multiple applications are supported by tracking Thread constructions via `checkAccess`. - * The Thread that constructed each Thread is used to map a new Thread to an application. - * This is not reliable on all jvms, so ThreadGroup creations are also tracked via - * `checkAccess` and traversed on demand to collect threads. - * This association of Threads with an application allows properly waiting for - * non-daemon threads to terminate or to interrupt the correct threads when terminating. - * It also allows disposing AWT windows if the application created any. - * Only one AWT application is supported at a time, however. - */ + * Simulates isolation via a SecurityManager. Multiple applications are supported by tracking Thread constructions via + * `checkAccess`. The Thread that constructed each Thread is used to map a new Thread to an application. This is not + * reliable on all jvms, so ThreadGroup creations are also tracked via `checkAccess` and traversed on demand to collect + * threads. This association of Threads with an application allows properly waiting for non-daemon threads to terminate + * or to interrupt the correct threads when terminating. It also allows disposing AWT windows if the application + * created any. Only one AWT application is supported at a time, however. + */ @nowarn private final class ScastieTrapExit(delegateManager: SecurityManager) extends SecurityManager { - /** Tracks the number of running applications in order to short-cut SecurityManager checks when no applications are active.*/ + /** + * Tracks the number of running applications in order to short-cut SecurityManager checks when no applications are + * active. + */ private[this] val running = new java.util.concurrent.atomic.AtomicInteger - /** Maps a thread or thread group to its originating application. The thread is represented by a unique identifier to avoid leaks. */ + /** + * Maps a thread or thread group to its originating application. The thread is represented by a unique identifier to + * avoid leaks. + */ private[this] val threadToApp = new CMap[ThreadID, App] /** Executes `f` in a managed context. */ @@ -160,6 +163,7 @@ private final class ScastieTrapExit(delegateManager: SecurityManager) extends Se running.decrementAndGet(); () } } + private[this] def runManaged0(f: Supplier[Unit], xlog: xsbti.Logger): Int = { val log: Logger = xlog val app = new App(f, log) @@ -182,9 +186,9 @@ private final class ScastieTrapExit(delegateManager: SecurityManager) extends Se } /** - * Wait for all non-daemon threads for `app` to exit, for an exception to be thrown in the main thread, - * or for `System.exit` to be called in a thread started by `app`. - */ + * Wait for all non-daemon threads for `app` to exit, for an exception to be thrown in the main thread, or for + * `System.exit` to be called in a thread started by `app`. + */ private[this] def finish(app: App, log: Logger): Int = { log.debug("Waiting for threads to exit or System.exit to be called.") waitForExit(app) @@ -207,8 +211,7 @@ private final class ScastieTrapExit(delegateManager: SecurityManager) extends Se } } // processThreads takes a snapshot of the threads at a given moment, so if there were only daemons, the application should shut down - if (!daemonsOnly) - waitForExit(app) + if (!daemonsOnly) waitForExit(app) } /** Gives managed applications a unique ID to use in the IDs of the main thread and thread group. */ @@ -216,17 +219,15 @@ private final class ScastieTrapExit(delegateManager: SecurityManager) extends Se private def nextID(): String = nextAppID.getAndIncrement.toHexString /** - * Represents an isolated application as simulated by [[ScastieTrapExit]]. - * `execute` is the application code to evaluate. - * `log` is used for debug logging. - */ + * Represents an isolated application as simulated by [[ScastieTrapExit]]. `execute` is the application code to + * evaluate. `log` is used for debug logging. + */ private final class App(val execute: Supplier[Unit], val log: Logger) extends Runnable { /** - * Tracks threads and groups created by this application. - * To avoid leaks, keys are a unique identifier and values are held via WeakReference. - * A TrieMap supports the necessary concurrent updates and snapshots. - */ + * Tracks threads and groups created by this application. To avoid leaks, keys are a unique identifier and values + * are held via WeakReference. A TrieMap supports the necessary concurrent updates and snapshots. + */ private[this] val threads = new TrieMap[ThreadID, WeakReference[Thread]] private[this] val groups = new TrieMap[ThreadID, WeakReference[ThreadGroup]] @@ -240,9 +241,9 @@ private final class ScastieTrapExit(delegateManager: SecurityManager) extends Se /** The ThreadGroup to use to try to track created threads. */ val mainGroup: ThreadGroup = new ThreadGroup("run-main-group-" + id) { private[this] val handler = new LoggingExceptionHandler(log, None) - override def uncaughtException(t: Thread, e: Throwable): Unit = - handler.uncaughtException(t, e) + override def uncaughtException(t: Thread, e: Throwable): Unit = handler.uncaughtException(t, e) } + val mainThread = new Thread(mainGroup, this, "run-main-" + id) /** Saves the ids of the creating thread and thread group to avoid tracking them as coming from this application. */ @@ -258,26 +259,25 @@ private final class ScastieTrapExit(delegateManager: SecurityManager) extends Se try execute.get() catch { case x: Throwable => - exitCode.set(1) //exceptions in the main thread cause the exit code to be 1 + exitCode.set(1) // exceptions in the main thread cause the exit code to be 1 throw x } } - /** Records a new group both in the global [[ScastieTrapExit]] manager and for this [[App]].*/ - def register(g: ThreadGroup): Unit = - if (g != null && g != creatorGroup && !isSystemGroup(g)) { - val groupID = computeID(g) - val old = groups.putIfAbsent(groupID, new WeakReference(g)) - if (old.isEmpty) { // wasn't registered - threadToApp.put(groupID, this) - () - } + /** Records a new group both in the global [[ScastieTrapExit]] manager and for this [[App]]. */ + def register(g: ThreadGroup): Unit = if (g != null && g != creatorGroup && !isSystemGroup(g)) { + val groupID = computeID(g) + val old = groups.putIfAbsent(groupID, new WeakReference(g)) + if (old.isEmpty) { // wasn't registered + threadToApp.put(groupID, this) + () } + } /** - * Records a new thread both in the global [[ScastieTrapExit]] manager and for this [[App]]. - * Its uncaught exception handler is configured to log exceptions through `log`. - */ + * Records a new thread both in the global [[ScastieTrapExit]] manager and for this [[App]]. Its uncaught exception + * handler is configured to log exceptions through `log`. + */ def register(t: Thread): Unit = { val threadID = computeID(t) if (!isDone(t) && threadID != creatorThreadID) { @@ -285,8 +285,7 @@ private final class ScastieTrapExit(delegateManager: SecurityManager) extends Se if (old.isEmpty) { // wasn't registered threadToApp.put(threadID, this) setExceptionHandler(t) - if (!awtUsed && isEventQueue(t)) - awtUsed = true + if (!awtUsed && isEventQueue(t)) awtUsed = true } } } @@ -314,11 +313,11 @@ private final class ScastieTrapExit(delegateManager: SecurityManager) extends Se cleanup(threads) cleanup(groups) } + private[this] def cleanup(resources: TrieMap[ThreadID, _]): Unit = { val snap = resources.readOnlySnapshot resources.clear() - for ((id, _) <- snap) - unregister(id) + for ((id, _) <- snap) unregister(id) } // only want to operate on unterminated threads @@ -332,19 +331,16 @@ private final class ScastieTrapExit(delegateManager: SecurityManager) extends Se val snap = threads.readOnlySnapshot for ((id, tref) <- snap) { val t = tref.get - if ((t eq null) || isDone(t)) - unregister(id) + if ((t eq null) || isDone(t)) unregister(id) else { f(t) - if (isDone(t)) - unregister(id) + if (isDone(t)) unregister(id) } } } // registers Threads from the tracked ThreadGroups - private[this] def addUntrackedThreads(): Unit = - groupThreadsSnapshot foreach register + private[this] def addUntrackedThreads(): Unit = groupThreadsSnapshot foreach register private[this] def groupThreadsSnapshot: Seq[Thread] = { val snap = groups.readOnlySnapshot.values.map(_.get).filter(_ != null) @@ -355,8 +351,8 @@ private final class ScastieTrapExit(delegateManager: SecurityManager) extends Se // the thread groups are accumulated in `accum` and then the threads in each are collected all at // once while they are all locked. This is the closest thing to a snapshot that can be accomplished. private[this] def threadsInGroups( - toProcess: List[ThreadGroup], - accum: List[ThreadGroup] + toProcess: List[ThreadGroup], + accum: List[ThreadGroup] ): List[Thread] = toProcess match { case group :: tail => // ThreadGroup implementation synchronizes on its methods, so by synchronizing here, we can workaround its quirks somewhat @@ -382,22 +378,21 @@ private final class ScastieTrapExit(delegateManager: SecurityManager) extends Se val childrenCount = group.enumerate(threads, false) threads.take(childrenCount).toList } + } private[this] def stopAllThreads(app: App): Unit = { // only try to dispose frames if we think the App used AWT // otherwise, we initialize AWT as a side effect of asking for the frames // also, we only assume one AWT application at a time - if (app.awtUsed) - disposeAllFrames(app.log) + if (app.awtUsed) disposeAllFrames(app.log) interruptAllThreads(app) } - private[this] def interruptAllThreads(app: App): Unit = - app processThreads { t => - if (!isSystemThread(t)) safeInterrupt(t, app.log) - else app.log.debug(s"Not interrupting system thread $t") - } + private[this] def interruptAllThreads(app: App): Unit = app processThreads { t => + if (!isSystemThread(t)) safeInterrupt(t, app.log) + else app.log.debug(s"Not interrupting system thread $t") + } /** Gets the managed application associated with Thread `t` */ private[this] def getApp(t: Thread): Option[App] = @@ -408,17 +403,17 @@ private final class ScastieTrapExit(delegateManager: SecurityManager) extends Se Option(group).flatMap(g => Option(threadToApp.get(computeID(g)))) /** - * Handles a valid call to `System.exit` by setting the exit code and - * interrupting remaining threads for the application associated with `t`, if one exists. - */ + * Handles a valid call to `System.exit` by setting the exit code and interrupting remaining threads for the + * application associated with `t`, if one exists. + */ private[this] def exitApp(t: Thread, status: Int): Unit = getApp(t) match { - case None => System.err.println(s"Could not exit($status): no application associated with $t") + case None => System.err.println(s"Could not exit($status): no application associated with $t") case Some(a) => a.exitCode.set(status) stopAllThreads(a) } - /** SecurityManager hook to trap calls to `System.exit` to avoid shutting down the whole JVM.*/ + /** SecurityManager hook to trap calls to `System.exit` to avoid shutting down the whole JVM. */ override def checkExit(status: Int): Unit = if (active) { val t = currentThread val stack = t.getStackTrace @@ -428,7 +423,7 @@ private final class ScastieTrapExit(delegateManager: SecurityManager) extends Se } } - /** This ensures that only actual calls to exit are trapped and not just calls to check if exit is allowed.*/ + /** This ensures that only actual calls to exit are trapped and not just calls to check if exit is allowed. */ private def isRealExit(element: StackTraceElement): Boolean = element.getClassName == "java.lang.Runtime" && element.getMethodName == "exit" @@ -442,19 +437,18 @@ private final class ScastieTrapExit(delegateManager: SecurityManager) extends Se override def checkExec(cmd: String): Unit = () override def checkPermission(perm: Permission): Unit = { - if (delegateManager ne null) - delegateManager.checkPermission(perm) + if (delegateManager ne null) delegateManager.checkPermission(perm) } + override def checkPermission(perm: Permission, context: AnyRef): Unit = { - if (delegateManager ne null) - delegateManager.checkPermission(perm, context) + if (delegateManager ne null) delegateManager.checkPermission(perm, context) } /** - * SecurityManager hook that is abused to record every created Thread and associate it with a managed application. - * This is not reliably called on different jvm implementations. On openjdk and similar jvms, the Thread constructor - * calls setPriority, which triggers this SecurityManager check. For Java 6 on OSX, this is not called, however. - */ + * SecurityManager hook that is abused to record every created Thread and associate it with a managed application. + * This is not reliably called on different jvm implementations. On openjdk and similar jvms, the Thread constructor + * calls setPriority, which triggers this SecurityManager check. For Java 6 on OSX, this is not called, however. + */ override def checkAccess(t: Thread): Unit = { if (active) { val group = t.getThreadGroup @@ -464,14 +458,13 @@ private final class ScastieTrapExit(delegateManager: SecurityManager) extends Se app.register(currentThread) } } - if (delegateManager ne null) - delegateManager.checkAccess(t) + if (delegateManager ne null) delegateManager.checkAccess(t) } /** - * This is specified to be called in every Thread's constructor and every time a ThreadGroup is created. - * This allows us to reliably track every ThreadGroup that is created and map it back to the constructing application. - */ + * This is specified to be called in every Thread's constructor and every time a ThreadGroup is created. This allows + * us to reliably track every ThreadGroup that is created and map it back to the constructing application. + */ override def checkAccess(tg: ThreadGroup): Unit = { if (active && !isSystemGroup(tg)) { noteAccess(tg) { app => @@ -480,15 +473,13 @@ private final class ScastieTrapExit(delegateManager: SecurityManager) extends Se } } - if (delegateManager ne null) - delegateManager.checkAccess(tg) + if (delegateManager ne null) delegateManager.checkAccess(tg) } private[this] def noteAccess(group: ThreadGroup)(f: App => Unit): Unit = getApp(currentThread) orElse getApp(group) foreach f - private[this] def isSystemGroup(group: ThreadGroup): Boolean = - (group != null) && (group.getName == "system") + private[this] def isSystemGroup(group: ThreadGroup): Boolean = (group != null) && (group.getName == "system") /** `true` if there is at least one application currently being managed. */ private[this] def active = running.get > 0 @@ -497,28 +488,28 @@ private final class ScastieTrapExit(delegateManager: SecurityManager) extends Se val allFrames = java.awt.Frame.getFrames if (allFrames.nonEmpty) { log.debug(s"Disposing ${allFrames.length} top-level windows...") - allFrames.foreach(_.dispose) // dispose all top-level windows, which will cause the AWT-EventQueue-* threads to exit + allFrames.foreach( + _.dispose + ) // dispose all top-level windows, which will cause the AWT-EventQueue-* threads to exit val waitSeconds = 2 log.debug(s"Waiting $waitSeconds s to let AWT thread exit.") Thread.sleep(waitSeconds * 1000L) // AWT Thread doesn't exit immediately, so wait to interrupt it } } - /** Returns true if the given thread is in the 'system' thread group or is an AWT thread other than AWT-EventQueue.*/ + /** Returns true if the given thread is in the 'system' thread group or is an AWT thread other than AWT-EventQueue. */ private def isSystemThread(t: Thread) = - if (t.getName.startsWith("AWT-")) - !isEventQueue(t) - else - isSystemGroup(t.getThreadGroup) + if (t.getName.startsWith("AWT-")) !isEventQueue(t) + else isSystemGroup(t.getThreadGroup) /** - * An App is identified as using AWT if it gets associated with the event queue thread. - * The event queue thread is not treated as a system thread. - */ + * An App is identified as using AWT if it gets associated with the event queue thread. The event queue thread is not + * treated as a system thread. + */ private[this] def isEventQueue(t: Thread): Boolean = t.getName.startsWith("AWT-EventQueue") } -/** A thread-safe, write-once, optional cell for tracking an application's exit code.*/ +/** A thread-safe, write-once, optional cell for tracking an application's exit code. */ private final class ExitCode { private var code: Option[Int] = None def set(c: Int): Unit = synchronized { code = code orElse Some(c) } @@ -526,16 +517,17 @@ private final class ExitCode { } /** - * The default uncaught exception handler for managed executions. - * It logs the thread and the exception. - */ + * The default uncaught exception handler for managed executions. It logs the thread and the exception. + */ private final class LoggingExceptionHandler( log: Logger, delegate: Option[Thread.UncaughtExceptionHandler] ) extends Thread.UncaughtExceptionHandler { + def uncaughtException(t: Thread, e: Throwable): Unit = { log.error("(" + t.getName + ") " + e.toString) log.trace(e) delegate.foreach(_.uncaughtException(t, e)) } + } diff --git a/scala-cli-runner/src/main/scala/org/scastie/scalacli/BspClient.scala b/scala-cli-runner/src/main/scala/org/scastie/scalacli/BspClient.scala index fd90c1f7c..b27ca3b36 100644 --- a/scala-cli-runner/src/main/scala/org/scastie/scalacli/BspClient.scala +++ b/scala-cli-runner/src/main/scala/org/scastie/scalacli/BspClient.scala @@ -1,51 +1,50 @@ package org.scastie.scalacli -import com.typesafe.scalalogging.Logger -import scala.collection.mutable.{Map, HashMap} -import org.eclipse.lsp4j.jsonrpc.Launcher -import ch.epfl.scala.bsp4j._ -import java.util.Collections -import java.util.concurrent.Executors -import java.util.concurrent.CompletableFuture import java.io.{InputStream, OutputStream} -import java.nio.file.Path -import scala.concurrent.Future - -import scala.concurrent.ExecutionContext.Implicits.global -import scala.jdk.FutureConverters._ -import scala.jdk.CollectionConverters._ -import scala.jdk.OptionConverters._ -import scala.sys.process.{ Process, ProcessBuilder } -import java.util.Optional -import org.scastie.api.Problem -import org.scastie.api.Severity -import org.scastie.api -import java.util.concurrent.TimeUnit -import org.eclipse.lsp4j.jsonrpc.messages.CancelParams +import java.io.PrintWriter +import java.lang import java.net.URI -import java.nio.file.Paths import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths import java.util.concurrent.atomic.AtomicReference -import scala.concurrent.duration.FiniteDuration -import cats.data.EitherT -import cats.syntax.all._ -import org.scastie.instrumentation.Instrument +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.Collections +import java.util.Optional +import scala.collection.mutable.{HashMap, Map} import scala.collection.mutable.ListBuffer import scala.concurrent.duration._ -import coursier._ -import org.scastie.buildinfo.BuildInfo -import org.scastie.api._ -import scala.util.Try +import scala.concurrent.duration.FiniteDuration +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.jdk.CollectionConverters._ +import scala.jdk.FutureConverters._ +import scala.jdk.OptionConverters._ +import scala.sys.process.{Process, ProcessBuilder} +import scala.util.control.NonFatal import scala.util.Failure import scala.util.Success -import com.google.gson.Gson +import scala.util.Try + +import cats.data.EitherT +import cats.syntax.all._ +import ch.epfl.scala.bsp4j._ import com.google.gson.internal.LinkedTreeMap -import org.eclipse.lsp4j.jsonrpc.messages.ResponseError -import scala.util.control.NonFatal +import com.google.gson.Gson +import com.typesafe.scalalogging.Logger +import coursier._ import org.apache.commons.io.IOUtils -import java.io.PrintWriter -import java.lang - +import org.eclipse.lsp4j.jsonrpc.messages.CancelParams +import org.eclipse.lsp4j.jsonrpc.messages.ResponseError +import org.eclipse.lsp4j.jsonrpc.Launcher +import org.scastie.api +import org.scastie.api._ +import org.scastie.api.Problem +import org.scastie.api.Severity +import org.scastie.buildinfo.BuildInfo +import org.scastie.instrumentation.Instrument object BspClient { case class BuildOutput(process: ProcessBuilder, diagnostics: List[Problem]) @@ -61,7 +60,7 @@ object BspClient { } case object Runner213 extends Runner { - val moduleName = "runner_2.13" + val moduleName = "runner_2.13" def matches(scalaBinaryVersion: String): Boolean = scalaBinaryVersion == "2.13" } @@ -73,15 +72,21 @@ object BspClient { object Runner { val all = List(Runner212, Runner213, Runner3) - def forScalaVersion(scalaBinaryVersion: String): Either[BuildError, Runner] = - all.find(_.matches(scalaBinaryVersion)) + def forScalaVersion(scalaBinaryVersion: String): Either[BuildError, Runner] = all + .find(_.matches(scalaBinaryVersion)) .toRight(InternalBspError(s"Unsupported Scala version: $scalaBinaryVersion")) + } private def getRunner(runner: Runner) = { - Fetch().addDependencies( - Dependency(Module(Organization("org.scastie"), ModuleName(runner.moduleName)), BuildInfo.versionRuntime) - ).run().filter(_.getName.contains(runner.moduleName)).map(_.toURI.toString).asRight + Fetch() + .addDependencies( + Dependency(Module(Organization("org.scastie"), ModuleName(runner.moduleName)), BuildInfo.versionRuntime) + ) + .run() + .filter(_.getName.contains(runner.moduleName)) + .map(_.toURI.toString) + .asRight } private def diagSeverityToSeverity(severity: DiagnosticSeverity): Severity = { @@ -92,19 +97,24 @@ object BspClient { else api.Error } - def diagnosticToProblem(isWorksheet: Boolean)(diag: Diagnostic): Problem = - Problem( - diagSeverityToSeverity(diag.getSeverity()), - Option(diag.getRange.getStart.getLine + 1), - diag.getMessage() - ) + def diagnosticToProblem(isWorksheet: Boolean)(diag: Diagnostic): Problem = Problem( + diagSeverityToSeverity(diag.getSeverity()), + Option(diag.getRange.getStart.getLine + 1), + diag.getMessage() + ) - val scalaCliExec = Seq("cs", "launch", "org.virtuslab.scala-cli:cliBootstrapped:latest.release", "-M", "scala.cli.ScalaCli", "--") + val scalaCliExec = + Seq("cs", "launch", "org.virtuslab.scala-cli:cliBootstrapped:latest.release", "-M", "scala.cli.ScalaCli", "--") } trait ScalaCliServer extends BuildServer with ScalaBuildServer with JvmBuildServer -class BspClient(coloredStackTrace: Boolean, workingDir: Path, compilationTimeout: FiniteDuration, reloadTimeout: FiniteDuration) { +class BspClient( + coloredStackTrace: Boolean, + workingDir: Path, + compilationTimeout: FiniteDuration, + reloadTimeout: FiniteDuration +) { import BspClient._ private implicit val defaultTimeout: FiniteDuration = FiniteDuration(10, TimeUnit.SECONDS) @@ -115,14 +125,15 @@ class BspClient(coloredStackTrace: Boolean, workingDir: Path, compilationTimeout private val localClient = new InnerClient() private val es = Executors.newFixedThreadPool(1) - val scalaCliExec = Seq("cs", "launch", "org.virtuslab.scala-cli:cliBootstrapped:latest.release", "-M", "scala.cli.ScalaCli", "--") - Process(scalaCliExec ++ Seq("clean", workingDir.toAbsolutePath.toString)).! + val scalaCliExec = + Seq("cs", "launch", "org.virtuslab.scala-cli:cliBootstrapped:latest.release", "-M", "scala.cli.ScalaCli", "--") + Process(scalaCliExec ++ Seq("clean", workingDir.toAbsolutePath.toString)).! Process(scalaCliExec ++ Seq("setup-ide", workingDir.toAbsolutePath.toString)).! private val processBuilder: java.lang.ProcessBuilder = new java.lang.ProcessBuilder() val logFile = workingDir.toAbsolutePath.resolve("bsp.error.log") processBuilder - .command((scalaCliExec ++ Seq("bsp", workingDir.toAbsolutePath.toString)):_*) + .command((scalaCliExec ++ Seq("bsp", workingDir.toAbsolutePath.toString)): _*) .redirectError(logFile.toFile) val scalaCliServer = processBuilder.start() @@ -144,13 +155,17 @@ class BspClient(coloredStackTrace: Boolean, workingDir: Path, compilationTimeout private val bspServer = bspLauncher.getRemoteProxy() private val listening = bspLauncher.startListening() - bspServer.buildInitialize(new InitializeBuildParams( - "BspClient", - "1.1.0", - Bsp4j.PROTOCOL_VERSION, - workingDir.toAbsolutePath.toUri.toString, - new BuildClientCapabilities(List("scala", "java").asJava) - )).get // Force to wait + bspServer + .buildInitialize( + new InitializeBuildParams( + "BspClient", + "1.1.0", + Bsp4j.PROTOCOL_VERSION, + workingDir.toAbsolutePath.toUri.toString, + new BuildClientCapabilities(List("scala", "java").asJava) + ) + ) + .get // Force to wait bspServer.onBuildInitialized() @@ -158,20 +173,24 @@ class BspClient(coloredStackTrace: Boolean, workingDir: Path, compilationTimeout type BspTask[T] = EitherT[Future, BuildError, T] - private def requestWithTimeout[T](f: ScalaCliServer => CompletableFuture[T])(implicit timeout: FiniteDuration): Future[T] = - f(bspServer).orTimeout(timeout.length, timeout.unit).asScala + private def requestWithTimeout[T](f: ScalaCliServer => CompletableFuture[T])( + implicit timeout: FiniteDuration + ): Future[T] = f(bspServer).orTimeout(timeout.length, timeout.unit).asScala - private def reloadWorkspace(retry: Int = 0): BspTask[Unit] = EitherT(requestWithTimeout(_.workspaceReload())(using reloadTimeout).flatMap { + private def reloadWorkspace(retry: Int = 0): BspTask[Unit] = EitherT( + requestWithTimeout(_.workspaceReload())( + using reloadTimeout + ).flatMap { case gsonMap: LinkedTreeMap[?, ?] if !gsonMap.isEmpty => - val gson = new Gson() - val error = gson.fromJson(gson.toJson(gsonMap), classOf[ResponseError]) - log.info(s"Reload failed: ${error.getMessage}") - if (retry < 3) { - log.info(s"Reload failed, retry #$retry/3") - reloadWorkspace(retry + 1).value - } else { - Future.successful(Left(InternalBspError(error.getMessage))) - } + val gson = new Gson() + val error = gson.fromJson(gson.toJson(gsonMap), classOf[ResponseError]) + log.info(s"Reload failed: ${error.getMessage}") + if (retry < 3) { + log.info(s"Reload failed, retry #$retry/3") + reloadWorkspace(retry + 1).value + } else { + Future.successful(Left(InternalBspError(error.getMessage))) + } case _ => Future.successful(().asRight) } ) @@ -200,21 +219,28 @@ class BspClient(coloredStackTrace: Boolean, workingDir: Path, compilationTimeout } } - private def compile(id: String, isWorksheet: Boolean, buildTargetId: BuildTargetIdentifier): BspTask[CompileResult] = EitherT { - val params: CompileParams = new CompileParams(Collections.singletonList(buildTargetId)) - requestWithTimeout(_.buildTargetCompile(params))(using compilationTimeout).map(compileResult => - compileResult.getStatusCode match { - case StatusCode.OK => Right(compileResult) - case StatusCode.ERROR => Left(CompilationError(diagnostics.getAndSet(Nil).map(diagnosticToProblem(isWorksheet)))) - case StatusCode.CANCELLED => Left(InternalBspError("Compilation cancelled")) - }) - } + private def compile(id: String, isWorksheet: Boolean, buildTargetId: BuildTargetIdentifier): BspTask[CompileResult] = + EitherT { + val params: CompileParams = new CompileParams(Collections.singletonList(buildTargetId)) + requestWithTimeout(_.buildTargetCompile(params))( + using compilationTimeout + ).map(compileResult => + compileResult.getStatusCode match { + case StatusCode.OK => Right(compileResult) + case StatusCode.ERROR => + Left(CompilationError(diagnostics.getAndSet(Nil).map(diagnosticToProblem(isWorksheet)))) + case StatusCode.CANCELLED => Left(InternalBspError("Compilation cancelled")) + } + ) + } private def getMainClass(mainClasses: List[JvmMainClass], isWorksheet: Boolean): Either[BuildError, JvmMainClass] = mainClasses match { - case mainClass :: Nil => mainClass.asRight - case mainClasses if isWorksheet && mainClasses.size == 2 => mainClasses.find(_.getClassName == Instrument.entryPointName) - .toRight(InternalBspError(s"Can't find proper main for worksheet build")) + case mainClass :: Nil => mainClass.asRight + case mainClasses if isWorksheet && mainClasses.size == 2 => + mainClasses + .find(_.getClassName == Instrument.entryPointName) + .toRight(InternalBspError(s"Can't find proper main for worksheet build")) case _ => Left(InternalBspError(s"Multiple main classes for target")) } @@ -234,8 +260,7 @@ class BspClient(coloredStackTrace: Boolean, workingDir: Path, compilationTimeout isWorksheet: Boolean ): BspTask[ProcessBuilder] = EitherT.fromEither { val javaBinURI = URI.create(buildTarget.scalabuildTarget.getJvmBuildTarget.getJavaHome()) - val javaBinPath = Try { Paths.get(javaBinURI).resolve("bin/java").toString } - .toEither + val javaBinPath = Try { Paths.get(javaBinURI).resolve("bin/java").toString }.toEither .leftMap(err => InternalBspError(s"Can't find java binary: $err")) for { @@ -245,14 +270,18 @@ class BspClient(coloredStackTrace: Boolean, workingDir: Path, compilationTimeout javaBin <- javaBinPath } yield { val classpath = (runnerClasspath ++ runSettings.getClasspath.asScala) - .map(uri => Paths.get(new URI(uri))).mkString(":") + .map(uri => Paths.get(new URI(uri))) + .mkString(":") val envVars = Map( "CLASSPATH" -> classpath ) ++ runSettings.getEnvironmentVariables.asScala - val cmd = Seq(javaBin, "org.scastie.runner.Runner") ++ runSettings.getJvmOptions().asScala ++ Seq(mainClass.getClassName, coloredStackTrace.toString) - val process = Process(cmd, cwd = new java.io.File(runSettings.getWorkingDirectory()), envVars.toSeq : _*) + val cmd = Seq(javaBin, "org.scastie.runner.Runner") ++ runSettings.getJvmOptions().asScala ++ Seq( + mainClass.getClassName, + coloredStackTrace.toString + ) + val process = Process(cmd, cwd = new java.io.File(runSettings.getWorkingDirectory()), envVars.toSeq: _*) process } @@ -282,8 +311,7 @@ class BspClient(coloredStackTrace: Boolean, workingDir: Path, compilationTimeout bspServer.buildShutdown().get(30, TimeUnit.SECONDS) log.info("buildShutdown finished.") } catch { - case NonFatal(e) => - log.error(s"Ignoring $e while shutting down BSP server") + case NonFatal(e) => log.error(s"Ignoring $e while shutting down BSP server") } finally { log.info("Process finalisation has started.") bspServer.onBuildExit() @@ -298,8 +326,7 @@ class BspClient(coloredStackTrace: Boolean, workingDir: Path, compilationTimeout scalaCliServer.onExit().get(30, TimeUnit.SECONDS) log.info("Process successfully terminated.") } catch { - case NonFatal(e) => - log.error(s"Ignoring $e while shutting down BSP server") + case NonFatal(e) => log.error(s"Ignoring $e while shutting down BSP server") } finally { if (scalaCliServer.isAlive()) { log.error("Destroying the process forcefully.") @@ -319,17 +346,20 @@ class BspClient(coloredStackTrace: Boolean, workingDir: Path, compilationTimeout } class InnerClient extends BuildClient { + def onBuildPublishDiagnostics(params: PublishDiagnosticsParams): Unit = { log.debug(s"PublishDiagnosticsParams: $params") val incomingDiagnostics = Option(params.getDiagnostics()).fold(List.empty[Diagnostic])(_.asScala.toList) if (params.getReset()) diagnostics.set(incomingDiagnostics) else diagnostics.getAndUpdate(_ ++ incomingDiagnostics) } + def onBuildLogMessage(params: LogMessageParams): Unit = log.debug(s"LogMessageParams: $params") - def onBuildShowMessage(params: ShowMessageParams): Unit = log.debug(s"ShowMessageParams: $params") - def onBuildTargetDidChange(params: DidChangeBuildTarget): Unit = log.debug(s"DidChangeBuildTarget: $params") - def onBuildTaskFinish(params: TaskFinishParams): Unit = log.debug(s"TaskFinishParams: $params") + def onBuildShowMessage(params: ShowMessageParams): Unit = log.debug(s"ShowMessageParams: $params") + def onBuildTargetDidChange(params: DidChangeBuildTarget): Unit = log.debug(s"DidChangeBuildTarget: $params") + def onBuildTaskFinish(params: TaskFinishParams): Unit = log.debug(s"TaskFinishParams: $params") def onBuildTaskProgress(params: TaskProgressParams): Unit = log.debug(s"TaskProgressParams: $params") def onBuildTaskStart(params: TaskStartParams): Unit = log.debug(s"TaskStartParams: $params") } + } diff --git a/scala-cli-runner/src/main/scala/org/scastie/scalacli/ScalaCliActor.scala b/scala-cli-runner/src/main/scala/org/scastie/scalacli/ScalaCliActor.scala index f26b0c862..cdb9c801b 100644 --- a/scala-cli-runner/src/main/scala/org/scastie/scalacli/ScalaCliActor.scala +++ b/scala-cli-runner/src/main/scala/org/scastie/scalacli/ScalaCliActor.scala @@ -1,58 +1,61 @@ package org.scastie.scalacli -import akka.actor.ActorSystem -import akka.actor.ActorRef -import akka.actor.Actor -import akka.actor.ActorLogging -import akka.actor.ActorContext -import org.scastie.util.ActorReconnecting -import org.scastie.util.ReconnectInfo -import org.scastie.api._ -import org.scastie.scalacli.ScalaCliRunner -import org.scastie.scalacli.BspClient +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.time.Instant +import java.util.concurrent.atomic.AtomicLong +import scala.concurrent.duration._ +import scala.concurrent.duration.FiniteDuration import scala.concurrent.ExecutionContext.Implicits.global +import scala.io.{Source => IOSource} +import scala.sys.process._ +import scala.util.control.NonFatal import scala.util.Failure import scala.util.Success -import java.time.Instant - -import scala.sys.process._ -import java.nio.charset.StandardCharsets -import scala.io.{Source => IOSource} -import scala.util.control.NonFatal +import akka.actor.Actor +import akka.actor.ActorContext +import akka.actor.ActorLogging +import akka.actor.ActorRef import akka.actor.ActorSelection -import scala.concurrent.duration.FiniteDuration -import org.scastie.util.ScalaCliActorTask -import akka.util.Timeout -import scala.concurrent.duration._ +import akka.actor.ActorSystem import akka.pattern.ask +import akka.util.Timeout import org.agrona.concurrent.status.AtomicCounter -import java.util.concurrent.atomic.AtomicLong -import java.nio.file.Files -import java.nio.file.Path +import org.scastie.api._ +import org.scastie.scalacli.BspClient +import org.scastie.scalacli.ScalaCliRunner import org.scastie.util._ - +import org.scastie.util.ActorReconnecting +import org.scastie.util.ReconnectInfo +import org.scastie.util.ScalaCliActorTask class ScalaCliActor( - isProduction: Boolean, - override val reconnectInfo: Option[ReconnectInfo], - coloredStackTrace: Boolean = true, - workingDir: Path = Files.createTempDirectory("scastie"), - compilationTimeout: FiniteDuration = 15.seconds, - runTimeout: FiniteDuration = 30.seconds, - reloadTimeout: FiniteDuration = 30.seconds, - ) extends Actor with ActorLogging with ActorReconnecting { - - private val runner: ScalaCliRunner = new ScalaCliRunner(coloredStackTrace, workingDir, compilationTimeout, reloadTimeout) - - override def receive: Receive = reconnectBehavior orElse { message => message match { - case task: ScalaCliActorTask => runTask(task, sender()) - case StopRunner => - runner.end() - sender() ! RunnerTerminated - case RunnerPing => sender() ! RunnerPong - case _ => - }} + isProduction: Boolean, + override val reconnectInfo: Option[ReconnectInfo], + coloredStackTrace: Boolean = true, + workingDir: Path = Files.createTempDirectory("scastie"), + compilationTimeout: FiniteDuration = 15.seconds, + runTimeout: FiniteDuration = 30.seconds, + reloadTimeout: FiniteDuration = 30.seconds +) extends Actor + with ActorLogging + with ActorReconnecting { + + private val runner: ScalaCliRunner = + new ScalaCliRunner(coloredStackTrace, workingDir, compilationTimeout, reloadTimeout) + + override def receive: Receive = reconnectBehavior orElse { message => + message match { + case task: ScalaCliActorTask => runTask(task, sender()) + case StopRunner => + runner.end() + sender() ! RunnerTerminated + case RunnerPing => sender() ! RunnerPong + case _ => + } + } override def tryConnect(context: ActorContext): Unit = { if (isProduction) { @@ -72,57 +75,75 @@ class ScalaCliActor( val progressId: AtomicLong = new AtomicLong(0L) val onOutput: ProcessOutput => Any = output => - sendProgress(progressActor, author, SnippetProgress.default.copy( + sendProgress( + progressActor, + author, + SnippetProgress.default.copy( id = Some(progressId.getAndIncrement()), ts = Some(Instant.now.toEpochMilli), snippetId = Some(snippetId), userOutput = Some(output), isDone = false - )) - - runner.runTask(snippetId, inputs, runTimeout, onOutput).map { - case Right(output) => - sendProgress(progressActor, author, SnippetProgress.default.copy( - id = Some(progressId.getAndIncrement()), - ts = Some(Instant.now.toEpochMilli), - snippetId = Some(snippetId), - isDone = true, - runtimeError = output.runtimeError, - buildOutput = makeOutput(s"Process exited with error code ${output.exitCode}"), - instrumentations = output.instrumentation, - compilationInfos = output.diagnostics - )) - case Left(compilationError: CompilationError) => - sendProgress(progressActor, author, SnippetProgress.default.copy( - id = Some(progressId.getAndIncrement()), - ts = Some(Instant.now.toEpochMilli), - snippetId = Some(snippetId), - compilationInfos = compilationError.diagnostics, - userOutput = makeOutput(Nil), - isDone = true - )) - case Left(BspTaskTimeout(msg)) => - log.warning("Timeout detected, restarting BSP") - runner.restart() - sendProgress(progressActor, author, buildErrorProgress(snippetId, msg, progressId.getAndIncrement(), isTimeout = true)) - case Left(RuntimeTimeout(msg)) => - sendProgress(progressActor, author, buildErrorProgress(snippetId, msg, progressId.getAndIncrement(), isTimeout = true)) - case Left(error) => - log.error(s"Error reported: ${error.msg}") - sendProgress(progressActor, author, buildErrorProgress(snippetId, error.msg, progressId.getAndIncrement())) - }.recover { - case error => + ) + ) + + runner + .runTask(snippetId, inputs, runTimeout, onOutput) + .map { + case Right(output) => sendProgress( + progressActor, + author, + SnippetProgress.default.copy( + id = Some(progressId.getAndIncrement()), + ts = Some(Instant.now.toEpochMilli), + snippetId = Some(snippetId), + isDone = true, + runtimeError = output.runtimeError, + buildOutput = makeOutput(s"Process exited with error code ${output.exitCode}"), + instrumentations = output.instrumentation, + compilationInfos = output.diagnostics + ) + ) + case Left(compilationError: CompilationError) => sendProgress( + progressActor, + author, + SnippetProgress.default.copy( + id = Some(progressId.getAndIncrement()), + ts = Some(Instant.now.toEpochMilli), + snippetId = Some(snippetId), + compilationInfos = compilationError.diagnostics, + userOutput = makeOutput(Nil), + isDone = true + ) + ) + case Left(BspTaskTimeout(msg)) => + log.warning("Timeout detected, restarting BSP") + runner.restart() + sendProgress( + progressActor, + author, + buildErrorProgress(snippetId, msg, progressId.getAndIncrement(), isTimeout = true) + ) + case Left(RuntimeTimeout(msg)) => sendProgress( + progressActor, + author, + buildErrorProgress(snippetId, msg, progressId.getAndIncrement(), isTimeout = true) + ) + case Left(error) => + log.error(s"Error reported: ${error.msg}") + sendProgress(progressActor, author, buildErrorProgress(snippetId, error.msg, progressId.getAndIncrement())) + } + .recover { case error => log.error(error, "FATAL ERROR") - } + } } private def sendProgress(progressActor: ActorRef, author: ActorRef, snippetProgress: SnippetProgress): Unit = { implicit val tm = Timeout(10.seconds) progressActor ! snippetProgress (author ? snippetProgress) - .recover { - case e => - log.error(e, s"error while saving progress $snippetProgress") + .recover { case e => + log.error(e, s"error while saving progress $snippetProgress") } } @@ -146,5 +167,4 @@ class ScalaCliActor( ) } - } diff --git a/scala-cli-runner/src/main/scala/org/scastie/scalacli/ScalaCliMain.scala b/scala-cli-runner/src/main/scala/org/scastie/scalacli/ScalaCliMain.scala index 736ed04bd..0214bc746 100644 --- a/scala-cli-runner/src/main/scala/org/scastie/scalacli/ScalaCliMain.scala +++ b/scala-cli-runner/src/main/scala/org/scastie/scalacli/ScalaCliMain.scala @@ -1,22 +1,21 @@ package org.scastie.scalacli -import org.scastie.util.ScastieFileUtil.writeRunningPid -import org.scastie.util.ReconnectInfo +import java.util.concurrent.TimeUnit +import scala.concurrent.duration._ +import scala.concurrent.Await import akka.actor.{ActorSystem, Props} import com.typesafe.config.ConfigFactory - -import scala.concurrent.Await -import scala.concurrent.duration._ -import java.util.concurrent.TimeUnit - +import org.scastie.util.ReconnectInfo +import org.scastie.util.ScastieFileUtil.writeRunningPid import org.slf4j.LoggerFactory /** - * This object provides the main endpoint for the Scala-CLI runner. - * Its role is to create and setup the ActorSystem and create the ScalaCli Actor + * This object provides the main endpoint for the Scala-CLI runner. Its role is to create and setup the ActorSystem and + * create the ScalaCli Actor */ object ScalaCliMain { + def main(args: Array[String]): Unit = { val logger = LoggerFactory.getLogger(getClass) @@ -102,4 +101,5 @@ object ScalaCliMain { () } + } diff --git a/scala-cli-runner/src/main/scala/org/scastie/scalacli/ScalaCliRunner.scala b/scala-cli-runner/src/main/scala/org/scastie/scalacli/ScalaCliRunner.scala index 85ee0c767..8b88c8bff 100644 --- a/scala-cli-runner/src/main/scala/org/scastie/scalacli/ScalaCliRunner.scala +++ b/scala-cli-runner/src/main/scala/org/scastie/scalacli/ScalaCliRunner.scala @@ -1,38 +1,38 @@ package org.scastie.scalacli -import org.scastie.api._ -import org.scastie.runtime.api._ -import RuntimeCodecs._ -import org.scastie.instrumentation.{InstrumentedInputs, InstrumentationFailureReport} -import com.typesafe.scalalogging.Logger +import java.io.{InputStream, OutputStream} +import java.lang import java.nio.file.{Files, Path, StandardOpenOption} +import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.CompletableFuture -import java.io.{InputStream, OutputStream} -import scala.concurrent.Future -import scala.concurrent.ExecutionContext.Implicits.global -import scala.sys.process._ -import org.scastie.instrumentation.InstrumentationFailure -import org.scastie.instrumentation.Instrument -import scala.util.control.NonFatal -import scala.concurrent.duration.Duration import java.util.concurrent.TimeUnit -import scala.concurrent.Await -import scala.util.Try -import scala.util.Success -import scala.util.Failure import java.util.concurrent.TimeoutException import scala.collection.concurrent.TrieMap -import org.scastie.buildinfo.BuildInfo -import scala.concurrent.duration.FiniteDuration -import io.circe._ -import io.circe.parser._ import scala.collection.mutable.ListBuffer -import akka.pattern.after -import java.lang -import java.util.concurrent.atomic.AtomicReference -import cats.syntax.all._ +import scala.concurrent.duration.Duration +import scala.concurrent.duration.FiniteDuration +import scala.concurrent.Await +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future import scala.jdk.FutureConverters._ +import scala.sys.process._ +import scala.util.control.NonFatal +import scala.util.Failure +import scala.util.Success +import scala.util.Try +import akka.pattern.after +import cats.syntax.all._ +import com.typesafe.scalalogging.Logger +import io.circe._ +import io.circe.parser._ +import org.scastie.api._ +import org.scastie.buildinfo.BuildInfo +import org.scastie.instrumentation.{InstrumentationFailureReport, InstrumentedInputs} +import org.scastie.instrumentation.Instrument +import org.scastie.instrumentation.InstrumentationFailure +import org.scastie.runtime.api._ +import RuntimeCodecs._ sealed trait ScalaCliError { val msg: String @@ -44,6 +44,7 @@ sealed trait ScastieRuntimeError extends ScalaCliError case class InvalidScalaVersion(version: String) extends BuildError { val msg = s"Invalid Scala version: $version" } + case class InstrumentationFailure(failure: InstrumentationFailureReport) extends BuildError { val msg = s"Instrumentation failure: $failure" } @@ -59,46 +60,59 @@ case class RuntimeTimeout(msg: String) extends ScastieRuntimeError case class BspTaskTimeout(msg: String) extends BuildError case class RunOutput( - instrumentation: List[Instrumentation], - diagnostics: List[Problem], - runtimeError: Option[org.scastie.runtime.api.RuntimeError], - exitCode: Int + instrumentation: List[Instrumentation], + diagnostics: List[Problem], + runtimeError: Option[org.scastie.runtime.api.RuntimeError], + exitCode: Int ) -class ScalaCliRunner(coloredStackTrace: Boolean, workingDir: Path, compilationTimeout: FiniteDuration, reloadTimeout: FiniteDuration) { +class ScalaCliRunner( + coloredStackTrace: Boolean, + workingDir: Path, + compilationTimeout: FiniteDuration, + reloadTimeout: FiniteDuration +) { private val log = Logger("ScalaCliRunner") private var bspClient = new BspClient(coloredStackTrace, workingDir, compilationTimeout, reloadTimeout) private val scalaMain = workingDir.resolve("Main.scala") Files.createDirectories(scalaMain.getParent()) - def runTask(snippetId: SnippetId, inputs: ScalaCliInputs, timeout: FiniteDuration, onOutput: ProcessOutput => Any): Future[Either[ScalaCliError, RunOutput]] = { + def runTask( + snippetId: SnippetId, + inputs: ScalaCliInputs, + timeout: FiniteDuration, + onOutput: ProcessOutput => Any + ): Future[Either[ScalaCliError, RunOutput]] = { log.info(s"Running task with snippetId=$snippetId") build(snippetId, inputs).flatMap { case Right((value, lineMapping)) => runForked(value, inputs.isWorksheetMode, onOutput, lineMapping) - case Left(value) => Future.successful(Left[ScalaCliError, RunOutput](value)) + case Left(value) => Future.successful(Left[ScalaCliError, RunOutput](value)) } } def build( snippetId: SnippetId, - inputs: BaseInputs, + inputs: BaseInputs ): Future[Either[ScalaCliError, (BspClient.BuildOutput, Int => Int)]] = { val (instrumentedInput, lineMapping) = InstrumentedInputs(inputs) match { case Right(value) => (value.inputs, value.lineMapping) - case Left(value) => + case Left(value) => log.error(s"Error while instrumenting: $value") (inputs, identity: Int => Int) } Files.write(scalaMain, instrumentedInput.code.getBytes) - bspClient.build(snippetId.base64UUID, inputs.isWorksheetMode, inputs.target).value.recover { - case timeout: TimeoutException => BspTaskTimeout("Build Server Timeout Exception").asLeft - case err => InternalBspError(err.getMessage).asLeft - } - .map { - case Right(buildOutput) => Right((buildOutput, lineMapping)) + bspClient + .build(snippetId.base64UUID, inputs.isWorksheetMode, inputs.target) + .value + .recover { + case timeout: TimeoutException => BspTaskTimeout("Build Server Timeout Exception").asLeft + case err => InternalBspError(err.getMessage).asLeft + } + .map { + case Right(buildOutput) => Right((buildOutput, lineMapping)) case Left(CompilationError(diagnostics)) => val mapped = diagnostics.map { p => val orig = p.line @@ -136,23 +150,28 @@ class ScalaCliRunner(coloredStackTrace: Boolean, workingDir: Path, compilationTi } } - val runProcess = bspRun.process.run(ProcessLogger.apply( - (fout: String) => forwardAndStorePrint(fout, ProcessOutputType.StdOut), - (ferr: String) => forwardAndStorePrint(ferr, ProcessOutputType.StdErr) - )) + val runProcess = bspRun.process.run( + ProcessLogger.apply( + (fout: String) => forwardAndStorePrint(fout, ProcessOutputType.StdOut), + (ferr: String) => forwardAndStorePrint(ferr, ProcessOutputType.StdErr) + ) + ) - val processResult = CompletableFuture.supplyAsync { () => runProcess.exitValue() }.orTimeout(10, TimeUnit.SECONDS).asScala + val processResult = + CompletableFuture.supplyAsync { () => runProcess.exitValue() }.orTimeout(10, TimeUnit.SECONDS).asScala processResult.onComplete(_ => runProcess.destroy()) - processResult.map { exitCode => - Right(RunOutput(instrumentations.get, bspRun.diagnostics, runtimeError.get, exitCode)) - }.recover { - case _: TimeoutException => - forwardAndStorePrint("Timeout exceeded.", ProcessOutputType.StdErr) - Left(RuntimeTimeout("Timeout exceeded.")) - case err => - forwardAndStorePrint(s"Unknown exception $err", ProcessOutputType.StdErr) - Left(InternalRuntimeError(s"Unknown exception $err")) - } + processResult + .map { exitCode => + Right(RunOutput(instrumentations.get, bspRun.diagnostics, runtimeError.get, exitCode)) + } + .recover { + case _: TimeoutException => + forwardAndStorePrint("Timeout exceeded.", ProcessOutputType.StdErr) + Left(RuntimeTimeout("Timeout exceeded.")) + case err => + forwardAndStorePrint(s"Unknown exception $err", ProcessOutputType.StdErr) + Left(InternalRuntimeError(s"Unknown exception $err")) + } } def restart(): Unit = { @@ -160,6 +179,5 @@ class ScalaCliRunner(coloredStackTrace: Boolean, workingDir: Path, compilationTi bspClient = new BspClient(coloredStackTrace, workingDir, compilationTimeout, reloadTimeout) } - def end(): Unit = - bspClient.end() + def end(): Unit = bspClient.end() } diff --git a/scala-cli-runner/src/test/scala/org/scastie/scalacli/ScalaCliRunnerTest.scala b/scala-cli-runner/src/test/scala/org/scastie/scalacli/ScalaCliRunnerTest.scala index e7c3eb23b..d8846e3ca 100644 --- a/scala-cli-runner/src/test/scala/org/scastie/scalacli/ScalaCliRunnerTest.scala +++ b/scala-cli-runner/src/test/scala/org/scastie/scalacli/ScalaCliRunnerTest.scala @@ -1,31 +1,31 @@ package org.scastie.scalacli -import org.scalatest.funsuite.AnyFunSuite -import org.scalatest.BeforeAndAfterAll -import org.scastie.util.ScastieFileUtil -import java.nio.file.Paths import java.nio.file.Files -import org.scastie.api.SnippetId -import org.scastie.api._ -import org.scastie.runtime.api._ -import scala.concurrent.Future +import java.nio.file.Paths import scala.concurrent.duration._ +import scala.concurrent.Future + import akka.actor.{ActorRef, ActorSystem, Props} -import akka.testkit.TestActor.AutoPilot import akka.testkit.{ImplicitSender, TestKit, TestProbe} -import org.scastie.runtime.api._ +import akka.testkit.TestActor.AutoPilot +import akka.testkit.TestActorRef +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.funsuite.AnyFunSuiteLike +import org.scalatest.BeforeAndAfterAll import org.scastie.api._ +import org.scastie.api.SnippetId +import org.scastie.runtime.api._ +import org.scastie.util.RunnerTerminated import org.scastie.util.SbtTask -import org.scalatest.BeforeAndAfterAll -import org.scalatest.funsuite.AnyFunSuiteLike - -import scala.concurrent.duration._ -import akka.testkit.TestActorRef import org.scastie.util.ScalaCliActorTask +import org.scastie.util.ScastieFileUtil import org.scastie.util.StopRunner -import org.scastie.util.RunnerTerminated -class ScalaCliRunnerTest extends TestKit(ActorSystem("ScalaCliRunnerTest")) with ImplicitSender with AnyFunSuiteLike with BeforeAndAfterAll { +class ScalaCliRunnerTest + extends TestKit(ActorSystem("ScalaCliRunnerTest")) + with ImplicitSender + with AnyFunSuiteLike + with BeforeAndAfterAll { val workingDir = Files.createTempDirectory("scastie") println(workingDir) @@ -104,38 +104,33 @@ class ScalaCliRunnerTest extends TestKit(ActorSystem("ScalaCliRunnerTest")) with test("Disable Predef #357") { val message = "No Predef!" - val testCode = - s"""//> using option -Yno-predef - |scala.Predef.println("$message")""".stripMargin - val verificationCode = - s"""//> using option -Yno-predef - |println("$message")""".stripMargin + val testCode = s"""//> using option -Yno-predef + |scala.Predef.println("$message")""".stripMargin + val verificationCode = s"""//> using option -Yno-predef + |println("$message")""".stripMargin runCode(testCode)(assertUserOutput(message)) runCode(verificationCode, allowFailure = true)(assertCompilationInfo { info => - assert(info.message == "Not found: println") + assert(info.message == "Not found: println") }) } test("Scala 2.12 support") { - val code = - s"""//> using scala 2.12 - |object Main extends App { - | println(util.Properties.versionNumberString) - |}""".stripMargin + val code = s"""//> using scala 2.12 + |object Main extends App { + | println(util.Properties.versionNumberString) + |}""".stripMargin runCode(code, isWorksheet = false)(assertUserOutput(output => assert(output.line.startsWith("2.12")))) } test("Scala 2.13 support") { - val code = - s"""//> using scala 2.13 - |object Main extends App { - | println(util.Properties.versionNumberString) - |}""".stripMargin + val code = s"""//> using scala 2.13 + |object Main extends App { + | println(util.Properties.versionNumberString) + |}""".stripMargin - val verificationCode = - s"""//> using scala 2.13 - |@main def hello = println("?")""".stripMargin + val verificationCode = s"""//> using scala 2.13 + |@main def hello = println("?")""".stripMargin runCode(code, isWorksheet = false)(assertUserOutput(output => assert(output.line.startsWith("2.13")))) runCode(verificationCode, allowFailure = true)(assertCompilationInfo { info => @@ -144,35 +139,29 @@ class ScalaCliRunnerTest extends TestKit(ActorSystem("ScalaCliRunnerTest")) with } test("Scala 3.0 support") { - val code = - s"""//> using scala 3 - |@main def hello = println(1 + 1)""".stripMargin + val code = s"""//> using scala 3 + |@main def hello = println(1 + 1)""".stripMargin runCode(code, isWorksheet = false)(assertUserOutput("2")) } test("Scala 3 support") { - val code = - s"""//> using scala 3 - |@main def hello = println(1 + 1)""".stripMargin + val code = s"""//> using scala 3 + |@main def hello = println(1 + 1)""".stripMargin runCode(code, isWorksheet = false)(assertUserOutput("2")) } - test("Scala 2.12 worksheet support") { - val code = - s"""//> using scala 2.12 - |println(util.Properties.versionNumberString)""".stripMargin - runCode(code)(assertUserOutput(output => assert(output.line.startsWith("2.12")))) + val code = s"""//> using scala 2.12 + |println(util.Properties.versionNumberString)""".stripMargin + runCode(code)(assertUserOutput(output => assert(output.line.startsWith("2.12")))) } test("Scala 2.13 worksheet support") { - val code = - s"""//> using scala 2.13 - |println(util.Properties.versionNumberString)""".stripMargin + val code = s"""//> using scala 2.13 + |println(util.Properties.versionNumberString)""".stripMargin - val verificationCode = - s"""//> using scala 2.13 - |@main def hello = println("?")""".stripMargin + val verificationCode = s"""//> using scala 2.13 + |@main def hello = println("?")""".stripMargin runCode(code)(assertUserOutput(output => assert(output.line.startsWith("2.13")))) runCode(verificationCode, allowFailure = true)(assertCompilationInfo { info => @@ -181,75 +170,65 @@ class ScalaCliRunnerTest extends TestKit(ActorSystem("ScalaCliRunnerTest")) with } test("Scala 3.0 worksheet support") { - val code = - s"""//> using scala 3.0 - |if true then println(1 + 1)""".stripMargin + val code = s"""//> using scala 3.0 + |if true then println(1 + 1)""".stripMargin runCode(code)(assertUserOutput("2")) } test("Scala 3 worksheet support") { - val code = - s"""//> using scala 3 - |if true then println(1 + 1)""".stripMargin + val code = s"""//> using scala 3 + |if true then println(1 + 1)""".stripMargin runCode(code)(assertUserOutput("2")) } test("avoid https://github.com/scala/bug/issues/8119") { - val code = - s"""//> using scala 2.12 - |val n = 0; val m = List(1).par.foreach(_ => n); println(1)""".stripMargin + val code = s"""//> using scala 2.12 + |val n = 0; val m = List(1).par.foreach(_ => n); println(1)""".stripMargin runCode(code)(assertUserOutput("1")) } test("no warnings on scala 3") { - val code = - s"""//> using option -Xfatal-warnings - |//> using scala 3 - |println(1 + 1)""".stripMargin + val code = s"""//> using option -Xfatal-warnings + |//> using scala 3 + |println(1 + 1)""".stripMargin runCode(code)(progress => progress.compilationInfos.isEmpty) } test("no warnings on 2.13") { - val code = - s"""//> using options -Xfatal-warnings -Xlint - |//> using scala 2.13 - |println(1 + 1)""".stripMargin + val code = s"""//> using options -Xfatal-warnings -Xlint + |//> using scala 2.13 + |println(1 + 1)""".stripMargin runCode(code)(progress => progress.compilationInfos.isEmpty) } test("no warnings on 2.12") { - val code = - s"""//> using options -Xfatal-warnings -Xlint - |//> using scala 2.12 - |println(1 + 1)""".stripMargin + val code = s"""//> using options -Xfatal-warnings -Xlint + |//> using scala 2.12 + |println(1 + 1)""".stripMargin runCode(code)(progress => progress.compilationInfos.isEmpty) } test("JVM 8") { - val code = - s"""//> using jvm 8 - |println(1 + 1)""".stripMargin + val code = s"""//> using jvm 8 + |println(1 + 1)""".stripMargin runCode(code)(assertUserOutput("2")) } test("JVM 11") { - val code = - s"""//> using jvm 11 - |println(1 + 1)""".stripMargin + val code = s"""//> using jvm 11 + |println(1 + 1)""".stripMargin runCode(code)(assertUserOutput("2")) } test("JVM 17") { - val code = - s"""//> using jvm 17 - |println(1 + 1)""".stripMargin + val code = s"""//> using jvm 17 + |println(1 + 1)""".stripMargin runCode(code)(assertUserOutput("2")) } test("JVM 21") { - val code = - s"""//> using jvm 21 - |println(1 + 1)""".stripMargin + val code = s"""//> using jvm 21 + |println(1 + 1)""".stripMargin runCode(code)(assertUserOutput("2")) } @@ -266,20 +245,22 @@ class ScalaCliRunnerTest extends TestKit(ActorSystem("ScalaCliRunnerTest")) with test("last line comment should not fail compilation in worksheet Scala 2") { val code = s"""//> using scala 2 - |println("Hello world!") - |// test comment""".stripMargin + |println("Hello world!") + |// test comment""".stripMargin runCode(code)(assertUserOutput("Hello world!")) } test("last line comment should not fail compilation in worksheet Scala 3") { val code = s"""//> using scala 3 - |println("Hello world!") - |// test comment""".stripMargin + |println("Hello world!") + |// test comment""".stripMargin runCode(code)(assertUserOutput("Hello world!")) } test("hide Playground from types") { - runCode("case class A(i:Int) extends AnyVal; A(1)")(_.instrumentations.headOption.exists(_.render == Value("A(1)", "A"))) + runCode("case class A(i:Int) extends AnyVal; A(1)")( + _.instrumentations.headOption.exists(_.render == Value("A(1)", "A")) + ) } test("#304 null pointer") { @@ -298,12 +279,11 @@ class ScalaCliRunnerTest extends TestKit(ActorSystem("ScalaCliRunnerTest")) with } test("Scala-CLI worksheet correct error position") { - val code = - s""" - | - |printl - | - |""".stripMargin + val code = s""" + | + |printl + | + |""".stripMargin runCode(code, allowFailure = true, isWorksheet = true)(assertCompilationInfo { info => assert(info.message == "Not found: printl - did you mean print? or perhaps printf or println?") @@ -313,12 +293,11 @@ class ScalaCliRunnerTest extends TestKit(ActorSystem("ScalaCliRunnerTest")) with } test("Scala-CLI non-worksheet correct error position") { - val code = - s""" - | - |printl - | - |""".stripMargin + val code = s""" + | + |printl + | + |""".stripMargin runCode(code, allowFailure = true, isWorksheet = false)(assertCompilationInfo { info => assert(info.message == "Illegal start of toplevel definition") @@ -326,17 +305,16 @@ class ScalaCliRunnerTest extends TestKit(ActorSystem("ScalaCliRunnerTest")) with }) } - private val macroCode = - """import scala.quoted._ - | - |object SleepMacro: - | inline def sleep(inline time: Int) = - | ${ wait('time) } - | - | def wait(x: Expr[Int])(using Quotes): Expr[Any] = - | Thread.sleep(x.valueOrAbort) - | x - |""".stripMargin + private val macroCode = """import scala.quoted._ + | + |object SleepMacro: + | inline def sleep(inline time: Int) = + | ${ wait('time) } + | + | def wait(x: Expr[Int])(using Quotes): Expr[Any] = + | Thread.sleep(x.valueOrAbort) + | x + |""".stripMargin test("No bsp timeout") { Files.writeString(workingDir.resolve("SleepMacro.scala"), macroCode) @@ -346,31 +324,36 @@ class ScalaCliRunnerTest extends TestKit(ActorSystem("ScalaCliRunnerTest")) with test("BSP Timeout") { Files.writeString(workingDir.resolve("SleepMacro.scala"), macroCode) - runCode(longCompilation(compilationTimeout.toMillis + 5000), isWorksheet = false, allowFailure = true)(assertCompilationInfo { info => - assert(info.message == "Build Server Timeout Exception" ) - }) + runCode(longCompilation(compilationTimeout.toMillis + 5000), isWorksheet = false, allowFailure = true)( + assertCompilationInfo { info => + assert(info.message == "Build Server Timeout Exception") + } + ) Files.delete(workingDir.resolve("SleepMacro.scala")) } - def longCompilation(time: Long): String = - s"""//> using scala 3 - |//> using file SleepMacro.scala - | - |@main def hello = - | SleepMacro.sleep($time) - | println("test") - |""".stripMargin + def longCompilation(time: Long): String = s"""//> using scala 3 + |//> using file SleepMacro.scala + | + |@main def hello = + | SleepMacro.sleep($time) + | println("test") + |""".stripMargin test("BSP Timeout Multiple snippets") { Files.writeString(workingDir.resolve("SleepMacro.scala"), macroCode) - runCode(longCompilation(compilationTimeout.toMillis + 5000), isWorksheet = false, allowFailure = true)(assertCompilationInfo { info => - assert(info.message == "Build Server Timeout Exception" ) - }) + runCode(longCompilation(compilationTimeout.toMillis + 5000), isWorksheet = false, allowFailure = true)( + assertCompilationInfo { info => + assert(info.message == "Build Server Timeout Exception") + } + ) runCode(longCompilation(100), isWorksheet = false, allowFailure = false)(assertUserOutput("test")) - runCode(longCompilation(compilationTimeout.toMillis + 5000), isWorksheet = false, allowFailure = true)(assertCompilationInfo { info => - assert(info.message == "Build Server Timeout Exception" ) - }) + runCode(longCompilation(compilationTimeout.toMillis + 5000), isWorksheet = false, allowFailure = true)( + assertCompilationInfo { info => + assert(info.message == "Build Server Timeout Exception") + } + ) runCode(longCompilation(100), isWorksheet = false, allowFailure = false)(assertUserOutput("test")) Files.delete(workingDir.resolve("SleepMacro.scala")) @@ -399,7 +382,7 @@ class ScalaCliRunnerTest extends TestKit(ActorSystem("ScalaCliRunnerTest")) with fishForMessage(1.minute, "RunnerTerminated") { case RunnerTerminated => true - case _ => false + case _ => false } TestKit.shutdownActorSystem(system, 1.minute, true) @@ -408,14 +391,27 @@ class ScalaCliRunnerTest extends TestKit(ActorSystem("ScalaCliRunnerTest")) with private val timeout = 45.seconds private val compilationTimeout = 25.seconds - val scalaCliActor = system.actorOf(Props(new ScalaCliActor(runTimeout = timeout, isProduction = false, reconnectInfo = None, coloredStackTrace = false, compilationTimeout = compilationTimeout, workingDir = workingDir))) + val scalaCliActor = system.actorOf( + Props( + new ScalaCliActor( + runTimeout = timeout, + isProduction = false, + reconnectInfo = None, + coloredStackTrace = false, + compilationTimeout = compilationTimeout, + workingDir = workingDir + ) + ) + ) private var currentId = 0 + private def snippetId = { val t = currentId currentId += 1 SnippetId(t.toString, None) } + private var firstRun = true private def run(inputs: ScalaCliInputs, allowFailure: Boolean = false)(fish: SnippetProgress => Boolean): Unit = { @@ -428,24 +424,25 @@ class ScalaCliRunnerTest extends TestKit(ActorSystem("ScalaCliRunnerTest")) with if (firstRun) timeout + 10.second else timeout - progressActor.fishForMessage(totalTimeout + 100.seconds) { - case progress: SnippetProgress => - val fishResult = fish(progress) - // println(progress -> fishResult) - if ((progress.isFailure && !allowFailure) || (progress.isDone && !fishResult)) - throw new Exception(s"Fail to meet expectation at ${progress}") - else fishResult + progressActor.fishForMessage(totalTimeout + 100.seconds) { case progress: SnippetProgress => + val fishResult = fish(progress) + // println(progress -> fishResult) + if ((progress.isFailure && !allowFailure) || (progress.isDone && !fishResult)) + throw new Exception(s"Fail to meet expectation at ${progress}") + else fishResult } firstRun = false } - private def runCode(code: String, allowFailure: Boolean = false, isWorksheet: Boolean = true)(fish: SnippetProgress => Boolean): Unit = { + private def runCode(code: String, allowFailure: Boolean = false, isWorksheet: Boolean = true)( + fish: SnippetProgress => Boolean + ): Unit = { run(ScalaCliInputs.default.copy(code = code, isWorksheetMode = isWorksheet), allowFailure)(fish) } private def assertUserOutput( - message: String, - outputType: ProcessOutputType = ProcessOutputType.StdOut + message: String, + outputType: ProcessOutputType = ProcessOutputType.StdOut )(progress: SnippetProgress): Boolean = { val gotHelloMessage = progress.userOutput.exists(out => out.line == message && out.tpe == outputType) // if (!gotHelloMessage) assert(progress.userOutput.isEmpty) diff --git a/server/src/main/scala/org/scastie/server/RestApiServer.scala b/server/src/main/scala/org/scastie/server/RestApiServer.scala index f7c043b75..ee0d6cf74 100644 --- a/server/src/main/scala/org/scastie/server/RestApiServer.scala +++ b/server/src/main/scala/org/scastie/server/RestApiServer.scala @@ -1,24 +1,24 @@ package org.scastie package web -import org.scastie.api._ -import balancer._ +import scala.concurrent.{ExecutionContext, Future} +import scala.concurrent.duration.DurationInt -import akka.pattern.ask import akka.actor.ActorRef -import akka.util.Timeout import akka.http.scaladsl.model.RemoteAddress - -import scala.concurrent.{Future, ExecutionContext} -import scala.concurrent.duration.DurationInt +import akka.pattern.ask +import akka.util.Timeout +import balancer._ +import org.scastie.api._ import org.scastie.storage.PolicyAcceptance class RestApiServer( dispatchActor: ActorRef, ip: RemoteAddress, maybeUser: Option[User] -)(implicit executionContext: ExecutionContext) - extends RestApi { +)( + implicit executionContext: ExecutionContext +) extends RestApi { implicit val timeout: Timeout = Timeout(20.seconds) @@ -90,8 +90,7 @@ class RestApiServer( def fetchUserSnippets(): Future[List[SnippetSummary]] = { maybeUser match { - case Some(user) => - dispatchActor + case Some(user) => dispatchActor .ask(FetchUserSnippets(user)) .mapTo[List[SnippetSummary]] case _ => Future.successful(Nil) @@ -101,8 +100,7 @@ class RestApiServer( @deprecated("Scheduled for removal", "2023-04-30") def getPrivacyPolicy(): Future[Boolean] = { maybeUser match { - case Some(user) => - dispatchActor + case Some(user) => dispatchActor .ask(GetPrivacyPolicy(user)) .mapTo[Boolean] case _ => Future.successful(true) @@ -112,8 +110,7 @@ class RestApiServer( @deprecated("Scheduled for removal", "2023-04-30") def acceptPrivacyPolicy(): Future[Boolean] = { maybeUser match { - case Some(user) => - dispatchActor + case Some(user) => dispatchActor .ask(SetPrivacyPolicy(user, true)) .mapTo[Boolean] case _ => Future.successful(true) @@ -123,8 +120,7 @@ class RestApiServer( @deprecated("Scheduled for removal", "2023-04-30") def removeUserFromPolicyStatus(): Future[Boolean] = { maybeUser match { - case Some(user) => - dispatchActor + case Some(user) => dispatchActor .ask(RemovePrivacyPolicy(user)) .mapTo[Boolean] case _ => Future.successful(true) @@ -134,11 +130,11 @@ class RestApiServer( @deprecated("Scheduled for removal", "2023-04-30") def removeAllUserSnippets(): Future[Boolean] = { maybeUser match { - case Some(user) => - dispatchActor + case Some(user) => dispatchActor .ask(RemoveAllUserSnippets(user)) .mapTo[Boolean] case _ => Future.successful(true) } } + } diff --git a/server/src/main/scala/org/scastie/server/ServerMain.scala b/server/src/main/scala/org/scastie/server/ServerMain.scala index 3f0e3e7b6..efb744890 100644 --- a/server/src/main/scala/org/scastie/server/ServerMain.scala +++ b/server/src/main/scala/org/scastie/server/ServerMain.scala @@ -1,22 +1,21 @@ package org.scastie.web +import scala.concurrent.duration._ +import scala.concurrent.Await +import scala.util.Failure +import scala.util.Success + import akka.actor.ActorSystem import akka.actor.Props import akka.http.scaladsl._ import akka.stream.ActorMaterializer import ch.megard.akka.http.cors.scaladsl.CorsDirectives._ +import com.typesafe.config.ConfigFactory +import com.typesafe.scalalogging.Logger import org.scastie.balancer._ import org.scastie.util.ScastieFileUtil import org.scastie.web.oauth2._ import org.scastie.web.routes._ -import com.typesafe.config.ConfigFactory -import com.typesafe.scalalogging.Logger - -import scala.concurrent.Await -import scala.concurrent.duration._ -import scala.util.Failure -import scala.util.Success - import server.Directives._ object ServerMain { @@ -34,9 +33,9 @@ object ServerMain { logger.info(config2.getString("hostname")) logger.info(config2.getInt("port").toString) - val config = ConfigFactory.load().getConfig("org.scastie") + val config = ConfigFactory.load().getConfig("org.scastie") val production = config.getBoolean("production") - val hostname = config.getString("web.hostname") + val hostname = config.getString("web.hostname") logger.info(s"Production: $production") logger.info(s"Server hostname: $hostname") @@ -49,8 +48,8 @@ object ServerMain { import system.dispatcher implicit val materializer: ActorMaterializer = ActorMaterializer() - val github = new Github() - val session = new GithubUserSession(system) + val github = new Github() + val session = new GithubUserSession(system) val userDirectives = new UserDirectives(session) val progressActor = system.actorOf( @@ -68,17 +67,16 @@ object ServerMain { name = "DispatchActor" ) - val apiRoutes = new ApiRoutes(dispatchActor, userDirectives).routes - val progressRoutes = new ProgressRoutes(progressActor).routes - val downloadRoutes = new DownloadRoutes(dispatchActor).routes - val statusRoutes = new StatusRoutes(statusActor, userDirectives).routes - val scalaJsRoutes = new ScalaJsRoutes(dispatchActor).routes - val oauthRoutes = new OAuth2Routes(github, session).routes + val apiRoutes = new ApiRoutes(dispatchActor, userDirectives).routes + val progressRoutes = new ProgressRoutes(progressActor).routes + val downloadRoutes = new DownloadRoutes(dispatchActor).routes + val statusRoutes = new StatusRoutes(statusActor, userDirectives).routes + val scalaJsRoutes = new ScalaJsRoutes(dispatchActor).routes + val oauthRoutes = new OAuth2Routes(github, session).routes val scalaLangRoutes = new ScalaLangRoutes(dispatchActor, userDirectives).routes val frontPageRoutes = new FrontPageRoutes(dispatchActor, production, hostname).routes - val routes = - oauthRoutes ~ + val routes = oauthRoutes ~ cors() { pathPrefix("api") { apiRoutes ~ diff --git a/server/src/main/scala/org/scastie/server/oauth2/Github.scala b/server/src/main/scala/org/scastie/server/oauth2/Github.scala index 789478142..c9f31d848 100644 --- a/server/src/main/scala/org/scastie/server/oauth2/Github.scala +++ b/server/src/main/scala/org/scastie/server/oauth2/Github.scala @@ -1,36 +1,38 @@ package org.scastie.web.oauth2 +import scala.concurrent.Future + import akka.actor.ActorSystem import akka.http.scaladsl._ -import akka.http.scaladsl.model.HttpMethods.POST -import akka.http.scaladsl.model.Uri._ import akka.http.scaladsl.model._ import akka.http.scaladsl.model.headers._ +import akka.http.scaladsl.model.HttpMethods.POST +import akka.http.scaladsl.model.Uri._ import akka.http.scaladsl.unmarshalling.Unmarshal -import org.scastie.api.User -import io.circe._ -import io.circe.generic.semiauto._ import com.typesafe.config.ConfigFactory import de.heikoseeberger.akkahttpcirce.FailFastCirceSupport - -import scala.concurrent.Future +import io.circe._ +import io.circe.generic.semiauto._ +import org.scastie.api.User case class AccessToken(access_token: String) -class Github(implicit system: ActorSystem) extends FailFastCirceSupport { +class Github( + implicit system: ActorSystem +) extends FailFastCirceSupport { import system.dispatcher implicit val userEncoder: Encoder[User] = deriveEncoder[User] implicit val userDecoder: Decoder[User] = deriveDecoder[User] implicit val readAccessToken: Decoder[AccessToken] = deriveDecoder[AccessToken] - private val config = - ConfigFactory.load().getConfig("org.scastie.web.oauth2") + private val config = ConfigFactory.load().getConfig("org.scastie.web.oauth2") val clientId: String = config.getString("client-id") private val clientSecret = config.getString("client-secret") private val redirectUri = config.getString("uri") + "/callback" def getUserWithToken(token: String): Future[User] = info(token) + def getUserWithOauth2(code: String): Future[User] = { def access = { Http() @@ -48,9 +50,7 @@ class Github(implicit system: ActorSystem) extends FailFastCirceSupport { headers = List(Accept(MediaTypes.`application/json`)) ) ) - .flatMap( - response => Unmarshal(response).to[AccessToken].map(_.access_token) - ) + .flatMap(response => Unmarshal(response).to[AccessToken].map(_.access_token)) } access.flatMap(info) @@ -68,4 +68,5 @@ class Github(implicit system: ActorSystem) extends FailFastCirceSupport { .singleRequest(fetchGithub(Path.Empty / "user")) .flatMap(response => Unmarshal(response).to[User]) } + } diff --git a/server/src/main/scala/org/scastie/server/oauth2/GithubUserSession.scala b/server/src/main/scala/org/scastie/server/oauth2/GithubUserSession.scala index 75252e07c..9cf925c9e 100644 --- a/server/src/main/scala/org/scastie/server/oauth2/GithubUserSession.scala +++ b/server/src/main/scala/org/scastie/server/oauth2/GithubUserSession.scala @@ -3,32 +3,27 @@ package org.scastie.web.oauth2 import java.lang.System.{lineSeparator => nl} import java.nio.file._ import java.util.UUID +import scala.collection.concurrent.TrieMap +import scala.jdk.CollectionConverters._ +import scala.util.control.NonFatal +import scala.util.Try import akka.actor.ActorSystem -import org.scastie.api.User import com.softwaremill.session._ import com.typesafe.config.ConfigFactory import com.typesafe.scalalogging.Logger -import io.circe.syntax._ import io.circe.parser._ - -import scala.collection.concurrent.TrieMap -import scala.jdk.CollectionConverters._ -import scala.util.Try -import scala.util.control.NonFatal +import io.circe.syntax._ +import org.scastie.api.User class GithubUserSession(system: ActorSystem) { val logger = Logger("GithubUserSession") - private val configuration = - ConfigFactory.load().getConfig("org.scastie.web") - private val usersFile = - Paths.get(configuration.getString("oauth2.users-file")) - private val usersSessions = - Paths.get(configuration.getString("oauth2.sessions-file")) + private val configuration = ConfigFactory.load().getConfig("org.scastie.web") + private val usersFile = Paths.get(configuration.getString("oauth2.users-file")) + private val usersSessions = Paths.get(configuration.getString("oauth2.sessions-file")) - private val sessionConfig = - SessionConfig.default(configuration.getString("session-secret")) + private val sessionConfig = SessionConfig.default(configuration.getString("session-secret")) private lazy val users = { val trie = TrieMap[UUID, User]() @@ -36,11 +31,11 @@ class GithubUserSession(system: ActorSystem) { trie } - implicit def serializer: SessionSerializer[UUID, String] = - new SingleValueSessionSerializer( - _.toString(), - (id: String) => Try { UUID.fromString(id) } - ) + implicit def serializer: SessionSerializer[UUID, String] = new SingleValueSessionSerializer( + _.toString(), + (id: String) => Try { UUID.fromString(id) } + ) + implicit val sessionManager = new SessionManager[UUID](sessionConfig) implicit val refreshTokenStorage = new ActorRefreshTokenStorage(system) @@ -96,6 +91,5 @@ class GithubUserSession(system: ActorSystem) { } } - def getUser(id: Option[UUID]): Option[User] = - id.flatMap(users.get) + def getUser(id: Option[UUID]): Option[User] = id.flatMap(users.get) } diff --git a/server/src/main/scala/org/scastie/server/oauth2/InMemoryRefreshTokenStorage.scala b/server/src/main/scala/org/scastie/server/oauth2/InMemoryRefreshTokenStorage.scala index a60e177a1..0006332a0 100644 --- a/server/src/main/scala/org/scastie/server/oauth2/InMemoryRefreshTokenStorage.scala +++ b/server/src/main/scala/org/scastie/server/oauth2/InMemoryRefreshTokenStorage.scala @@ -1,15 +1,14 @@ package org.scastie.web.oauth2 -import com.softwaremill.session.{RefreshTokenData, RefreshTokenStorage, RefreshTokenLookupResult} +import java.util.UUID +import scala.collection.mutable +import scala.concurrent.duration._ +import scala.concurrent.Future + import akka.actor.{Actor, ActorSystem, Props} import akka.pattern.ask import akka.util.Timeout - -import scala.concurrent.Future -import scala.concurrent.duration._ -import scala.collection.mutable - -import java.util.UUID +import com.softwaremill.session.{RefreshTokenData, RefreshTokenLookupResult, RefreshTokenStorage} private[oauth2] case class SessionStorage(session: UUID, tokenHash: String, expires: Long) @@ -25,16 +24,19 @@ class ActorRefreshTokenStorage(system: ActorSystem) extends RefreshTokenStorage[ impl ! Store(data) Future.successful(()) } + def remove(selector: String): Future[Unit] = { impl ! Remove(selector) Future.successful(()) } + def schedule[S](after: Duration)(op: => Future[S]): Unit = { after match { case finite: FiniteDuration => system.scheduler.scheduleOnce(finite)(op) case _: Duration.Infinite => () } } + } private[oauth2] case class Lookup(selector: String) @@ -43,18 +45,15 @@ private[oauth2] case class Remove(selector: String) class ActorRefreshTokenStorageImpl() extends Actor { private val storage = mutable.Map[String, SessionStorage]() + override def receive: Receive = { case Lookup(selector) => - val lookupResult = - storage - .get(selector) - .map( - s => RefreshTokenLookupResult(s.tokenHash, s.expires, () => s.session) - ) + val lookupResult = storage + .get(selector) + .map(s => RefreshTokenLookupResult(s.tokenHash, s.expires, () => s.session)) sender() ! lookupResult - case Store(data) => - storage.put(data.selector, SessionStorage(data.forSession, data.tokenHash, data.expires)) - case Remove(selector) => - storage.remove(selector) + case Store(data) => storage.put(data.selector, SessionStorage(data.forSession, data.tokenHash, data.expires)) + case Remove(selector) => storage.remove(selector) } + } diff --git a/server/src/main/scala/org/scastie/server/oauth2/UserDirectives.scala b/server/src/main/scala/org/scastie/server/oauth2/UserDirectives.scala index 352f45ebb..7a9be575c 100644 --- a/server/src/main/scala/org/scastie/server/oauth2/UserDirectives.scala +++ b/server/src/main/scala/org/scastie/server/oauth2/UserDirectives.scala @@ -1,21 +1,20 @@ package org.scastie.web.oauth2 -import org.scastie.api.User +import scala.concurrent.ExecutionContext import akka.http.scaladsl._ -import server._ - import com.softwaremill.session._ +import org.scastie.api.User +import server._ import SessionDirectives._ import SessionOptions._ -import scala.concurrent.ExecutionContext - class UserDirectives( session: GithubUserSession -)(implicit val executionContext: ExecutionContext) { +)( + implicit val executionContext: ExecutionContext +) { import session._ - def optionalLogin: Directive1[Option[User]] = - optionalSession(refreshable, usingCookies).map(getUser) + def optionalLogin: Directive1[Option[User]] = optionalSession(refreshable, usingCookies).map(getUser) } diff --git a/server/src/main/scala/org/scastie/server/routes/ApiRoutes.scala b/server/src/main/scala/org/scastie/server/routes/ApiRoutes.scala index 061477768..a98aca425 100644 --- a/server/src/main/scala/org/scastie/server/routes/ApiRoutes.scala +++ b/server/src/main/scala/org/scastie/server/routes/ApiRoutes.scala @@ -2,103 +2,89 @@ package org.scastie.web.routes import akka.actor.{ActorRef, ActorSystem} import akka.http.scaladsl.coding.Coders.Gzip -import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.{Directive1, Route} +import akka.http.scaladsl.server.Directives._ +import de.heikoseeberger.akkahttpcirce.FailFastCirceSupport import org.scastie.api._ +import org.scastie.server.utils.NightlyVersionFetcher import org.scastie.web._ import org.scastie.web.oauth2._ -import org.scastie.server.utils.NightlyVersionFetcher -import de.heikoseeberger.akkahttpcirce.FailFastCirceSupport class ApiRoutes( dispatchActor: ActorRef, userDirectives: UserDirectives -)(implicit system: ActorSystem) - extends FailFastCirceSupport { +)( + implicit system: ActorSystem +) extends FailFastCirceSupport { import system.dispatcher import userDirectives.optionalLogin - val withRestApiServer: Directive1[RestApiServer] = - (extractClientIP & optionalLogin).tmap { - case (remoteAddress, user) => - new RestApiServer(dispatchActor, remoteAddress, user) - } + val withRestApiServer: Directive1[RestApiServer] = (extractClientIP & optionalLogin).tmap { + case (remoteAddress, user) => new RestApiServer(dispatchActor, remoteAddress, user) + } - val routes: Route = - withRestApiServer( - server => + val routes: Route = withRestApiServer(server => + concat( + post( + concat( + path("run")( + entity(as[BaseInputs])(inputs => complete(server.run(inputs))) + ), + path("save")( + entity(as[BaseInputs])(inputs => complete(server.save(inputs))) + ), + path("update")( + entity(as[EditInputs])(editInputs => complete(server.update(editInputs))) + ), + path("fork")( + entity(as[EditInputs])(editInputs => complete(server.fork(editInputs))) + ), + path("delete")( + entity(as[SnippetId])(snippetId => complete(server.delete(snippetId))) + ), + path("format")( + entity(as[FormatRequest])(request => complete(server.format(request))) + ) + ) + ), + encodeResponseWith(Gzip)( + get( + concat( + snippetIdStart("snippets")(sid => complete(server.fetch(sid))), + path("old-snippets" / IntNumber)(id => complete(server.fetchOld(id))), + path("user" / "settings")( + complete(server.fetchUser()) + ), + path("user" / "snippets")( + complete(server.fetchUserSnippets()) + ), + path("nightly-raw" / "scala2" / Segment) { prefix => + complete(NightlyVersionFetcher.getLatestScala2Nightly(prefix)) + }, + path("nightly-raw" / "scala3") { + complete(NightlyVersionFetcher.getLatestScala3Nightly) + } + ) + ) + ), + post( concat( - post( - concat( - path("run")( - entity(as[BaseInputs])(inputs => complete(server.run(inputs))) - ), - path("save")( - entity(as[BaseInputs])(inputs => complete(server.save(inputs))) - ), - path("update")( - entity(as[EditInputs])( - editInputs => complete(server.update(editInputs)) - ) - ), - path("fork")( - entity(as[EditInputs])( - editInputs => complete(server.fork(editInputs)) - ) - ), - path("delete")( - entity(as[SnippetId])( - snippetId => complete(server.delete(snippetId)) - ) - ), - path("format")( - entity(as[FormatRequest])( - request => complete(server.format(request)) - ) - ) - ) + path("user" / "privacyPolicyStatus")( + complete(server.getPrivacyPolicy()) ), - encodeResponseWith(Gzip)( - get( - concat( - snippetIdStart("snippets")( - sid => complete(server.fetch(sid)) - ), - path("old-snippets" / IntNumber)( - id => complete(server.fetchOld(id)) - ), - path("user" / "settings")( - complete(server.fetchUser()) - ), - path("user" / "snippets")( - complete(server.fetchUserSnippets()) - ), - path("nightly-raw" / "scala2" / Segment) { prefix => - complete(NightlyVersionFetcher.getLatestScala2Nightly(prefix)) - }, - path("nightly-raw" / "scala3") { - complete(NightlyVersionFetcher.getLatestScala3Nightly) - } - ) - ) + path("user" / "acceptPrivacyPolicy")( + complete(server.acceptPrivacyPolicy()) ), - post( - concat( - path("user" / "privacyPolicyStatus")( - complete(server.getPrivacyPolicy()) - ), - path("user" / "acceptPrivacyPolicy")( - complete(server.acceptPrivacyPolicy()) - ), - path("user" / "removeUserFromPolicyStatus")( - complete(server.removeUserFromPolicyStatus()) - ), - path("user" / "removeAllUserSnippets")( - complete(server.removeAllUserSnippets()) - ), - ) + path("user" / "removeUserFromPolicyStatus")( + complete(server.removeUserFromPolicyStatus()) + ), + path("user" / "removeAllUserSnippets")( + complete(server.removeAllUserSnippets()) ) ) + ) ) + ) + } diff --git a/server/src/main/scala/org/scastie/server/routes/DownloadRoutes.scala b/server/src/main/scala/org/scastie/server/routes/DownloadRoutes.scala index 3360665b5..36094e5b0 100644 --- a/server/src/main/scala/org/scastie/server/routes/DownloadRoutes.scala +++ b/server/src/main/scala/org/scastie/server/routes/DownloadRoutes.scala @@ -1,33 +1,27 @@ package org.scastie.web.routes -import org.scastie.balancer.DownloadSnippet +import java.nio.file.Path +import scala.concurrent.duration.DurationInt +import akka.actor.ActorRef import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Route - -import akka.actor.ActorRef import akka.pattern.ask - -import java.nio.file.Path - import akka.util.Timeout -import scala.concurrent.duration.DurationInt +import org.scastie.balancer.DownloadSnippet class DownloadRoutes(dispatchActor: ActorRef) { implicit val timeout = Timeout(5.seconds) - val routes: Route = - get { - snippetIdStart("download")( - sid => - onSuccess((dispatchActor ? DownloadSnippet(sid)).mapTo[Option[Path]]) { - case Some(path) => - getFromFile(path.toFile) - case None => - throw new Exception( - s"Can't serve project ${sid.base64UUID} to user ${sid.user.getOrElse("anon")}" - ) - } - ) - } + val routes: Route = get { + snippetIdStart("download")(sid => + onSuccess((dispatchActor ? DownloadSnippet(sid)).mapTo[Option[Path]]) { + case Some(path) => getFromFile(path.toFile) + case None => throw new Exception( + s"Can't serve project ${sid.base64UUID} to user ${sid.user.getOrElse("anon")}" + ) + } + ) + } + } diff --git a/server/src/main/scala/org/scastie/server/routes/FrontPageRoutes.scala b/server/src/main/scala/org/scastie/server/routes/FrontPageRoutes.scala index f617b2373..81dbd32bd 100644 --- a/server/src/main/scala/org/scastie/server/routes/FrontPageRoutes.scala +++ b/server/src/main/scala/org/scastie/server/routes/FrontPageRoutes.scala @@ -1,47 +1,53 @@ package org.scastie.web.routes +import scala.concurrent.duration.DurationInt +import scala.concurrent.ExecutionContext +import scala.concurrent.Future + import akka.actor.ActorRef import akka.http.scaladsl.coding.Coders.Gzip import akka.http.scaladsl.coding.Coders.NoCoding -import akka.http.scaladsl.model.HttpEntity import akka.http.scaladsl.model._ import akka.http.scaladsl.model.headers.{`Cache-Control`, CacheDirectives} +import akka.http.scaladsl.model.HttpEntity import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Route import akka.http.scaladsl.server.RouteResult import akka.pattern.ask -import akka.stream.Materializer import akka.stream.scaladsl.StreamConverters +import akka.stream.Materializer import akka.util.ByteString import akka.util.Timeout +import org.apache.commons.text.StringEscapeUtils import org.scastie.api.FetchResult import org.scastie.api.SnippetId import org.scastie.api.SnippetUserPart import org.scastie.balancer.FetchSnippet import org.scastie.util.Base64UUID -import org.apache.commons.text.StringEscapeUtils - -import scala.concurrent.ExecutionContext -import scala.concurrent.Future -import scala.concurrent.duration.DurationInt -class FrontPageRoutes(dispatchActor: ActorRef, production: Boolean, hostname: String)(implicit ec: ExecutionContext, mat: Materializer) { +class FrontPageRoutes(dispatchActor: ActorRef, production: Boolean, hostname: String)( + implicit ec: ExecutionContext, + mat: Materializer +) { implicit val timeout: Timeout = Timeout(20.seconds) + private val placeholders = List( - "Scastie can run any Scala program with any library in your browser. You don’t need to download or install anything.", + "Scastie can run any Scala program with any library in your browser. You don’t need to download or install anything." ) private val indexResource = "public/index.html" - private val indexResourceContent = Future.traverse(Option(getClass.getClassLoader.getResource(indexResource)).toList) { url => - StreamConverters.fromInputStream(() => url.openStream()).runFold("")(_ + _.utf8String) - } + + private val indexResourceContent = + Future.traverse(Option(getClass.getClassLoader.getResource(indexResource)).toList) { url => + StreamConverters.fromInputStream(() => url.openStream()).runFold("")(_ + _.utf8String) + } + private val index = getFromResource(indexResource) private def embeddedResource(snippetId: SnippetId, theme: Option[String]): String = { val user = snippetId.user match { - case Some(SnippetUserPart(login, update)) => - s"user: '$login', update: $update," - case None => "" + case Some(SnippetUserPart(login, update)) => s"user: '$login', update: $update," + case None => "" } val themePart = theme match { @@ -78,26 +84,33 @@ class FrontPageRoutes(dispatchActor: ActorRef, production: Boolean, hostname: St respondWithHeader(`Cache-Control`(CacheDirectives.`no-cache`))( concat( path("embedded.js")( - getFromResource("public/embedded/embedded.js", ContentType(MediaTypes.`application/javascript`, HttpCharsets.`UTF-8`)) + getFromResource( + "public/embedded/embedded.js", + ContentType(MediaTypes.`application/javascript`, HttpCharsets.`UTF-8`) + ) ), path("public" / "embedded.css")( getFromResource("public/embedded/style.css", ContentType(MediaTypes.`text/css`, HttpCharsets.`UTF-8`)) ), path("public" / "tree-sitter.wasm")( - getFromResource("public/tree-sitter.wasm", ContentType(MediaType.applicationBinary("wasm", MediaType.Compressible, "wasm"))) + getFromResource( + "public/tree-sitter.wasm", + ContentType(MediaType.applicationBinary("wasm", MediaType.Compressible, "wasm")) + ) ), path("public" / "tree-sitter-scala.wasm")( - getFromResource("public/tree-sitter-scala.wasm", ContentType(MediaType.applicationBinary("wasm", MediaType.Compressible, "wasm"))) + getFromResource( + "public/tree-sitter-scala.wasm", + ContentType(MediaType.applicationBinary("wasm", MediaType.Compressible, "wasm")) + ) ), path("public" / "highlights.scm")( getFromResource("public/highlights.scm", ContentType(MediaTypes.`text/css`, HttpCharsets.`UTF-8`)) - ), + ) ) ), respondWithHeader(`Cache-Control`(CacheDirectives.immutableDirective))( - path("public" / Remaining)( - path => getFromResource("public/" + path) - ), + path("public" / Remaining)(path => getFromResource("public/" + path)) ), pathSingleSlash(index), snippetId { snippetId => ctx => @@ -105,30 +118,35 @@ class FrontPageRoutes(dispatchActor: ActorRef, production: Boolean, hostname: St s <- dispatchActor.ask(FetchSnippet(snippetId)).mapTo[Option[FetchResult]] c <- indexResourceContent r <- index(ctx) - } yield - (r, c, s) match { - case (r: RouteResult.Complete, List(c), Some(s)) if r.response.status.intValue() == 200 => - val code = StringEscapeUtils.escapeHtml4(s.inputs.code) - r.copy( - response = r.response.withEntity( - HttpEntity.Strict( - r.response.entity.contentType, - ByteString.fromString(placeholders.foldLeft(c)(_.replace(_, code))), - ) + } yield (r, c, s) match { + case (r: RouteResult.Complete, List(c), Some(s)) if r.response.status.intValue() == 200 => + val code = StringEscapeUtils.escapeHtml4(s.inputs.code) + r.copy( + response = r.response.withEntity( + HttpEntity.Strict( + r.response.entity.contentType, + ByteString.fromString(placeholders.foldLeft(c)(_.replace(_, code))) ) ) - case _ => r - } + ) + case _ => r + } }, parameter("theme".?) { theme => snippetIdExtension(".js") { sid => complete { - HttpResponse(entity = HttpEntity(ContentType(MediaTypes.`application/javascript`, HttpCharsets.`UTF-8`), embeddedResource(sid, theme))) + HttpResponse(entity = + HttpEntity( + ContentType(MediaTypes.`application/javascript`, HttpCharsets.`UTF-8`), + embeddedResource(sid, theme) + ) + ) } } }, - index, + index ) ) ) + } diff --git a/server/src/main/scala/org/scastie/server/routes/OAuth2Routes.scala b/server/src/main/scala/org/scastie/server/routes/OAuth2Routes.scala index e4ab01942..a764cefd4 100644 --- a/server/src/main/scala/org/scastie/server/routes/OAuth2Routes.scala +++ b/server/src/main/scala/org/scastie/server/routes/OAuth2Routes.scala @@ -2,85 +2,82 @@ package org.scastie package web package routes -import oauth2._ - -import com.softwaremill.session.SessionDirectives._ -import com.softwaremill.session.SessionOptions._ -import com.softwaremill.session.CsrfDirectives._ -import com.softwaremill.session.CsrfOptions._ +import scala.concurrent.ExecutionContext import akka.http.scaladsl.model._ -import akka.http.scaladsl.model.Uri.Query -import akka.http.scaladsl.model.StatusCodes.TemporaryRedirect import akka.http.scaladsl.model.headers.Referer +import akka.http.scaladsl.model.StatusCodes.TemporaryRedirect +import akka.http.scaladsl.model.Uri.Query import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Route - -import scala.concurrent.ExecutionContext +import com.softwaremill.session.CsrfDirectives._ +import com.softwaremill.session.CsrfOptions._ +import com.softwaremill.session.SessionDirectives._ +import com.softwaremill.session.SessionOptions._ +import oauth2._ class OAuth2Routes(github: Github, session: GithubUserSession)( implicit val executionContext: ExecutionContext ) { import session._ - val routes: Route = - get( - concat( - path("login") { - parameter("home".?)( - home => - optionalHeaderValueByType[Referer](()) { referrer => - redirect( - Uri("https://github.com/login/oauth/authorize").withQuery( - Query( - "client_id" -> github.clientId, - "state" -> { - val homeUri = "/" - if (home.isDefined) homeUri - else referrer.map(_.value).getOrElse(homeUri) - } - ) - ), - TemporaryRedirect + val routes: Route = get( + concat( + path("login") { + parameter("home".?)(home => + optionalHeaderValueByType[Referer](()) { referrer => + redirect( + Uri("https://github.com/login/oauth/authorize").withQuery( + Query( + "client_id" -> github.clientId, + "state" -> { + val homeUri = "/" + if (home.isDefined) homeUri + else referrer.map(_.value).getOrElse(homeUri) + } ) - } - ) - }, - path("logout") { - headerValueByType[Referer](()) { referrer => - requiredSession(refreshable, usingCookies) { _ => - invalidateSession(refreshable, usingCookies) { ctx => - ctx.complete( - HttpResponse( - status = TemporaryRedirect, - headers = headers.Location(Uri(referrer.value)) :: Nil, - entity = HttpEntity.Empty - ) + ), + TemporaryRedirect + ) + } + ) + }, + path("logout") { + headerValueByType[Referer](()) { referrer => + requiredSession(refreshable, usingCookies) { _ => + invalidateSession(refreshable, usingCookies) { ctx => + ctx.complete( + HttpResponse( + status = TemporaryRedirect, + headers = headers.Location(Uri(referrer.value)) :: Nil, + entity = HttpEntity.Empty ) - } + ) } } - }, - pathPrefix("callback") { - pathEnd { - parameters("code", "state".?) { (code, state) => - onSuccess(github.getUserWithOauth2(code)) { user => - setSession(refreshable, usingCookies, session.addUser(user)) { - setNewCsrfToken(checkHeader) { ctx => - ctx.complete( - HttpResponse( - status = TemporaryRedirect, - headers = headers - .Location(Uri(state.getOrElse("/"))) :: Nil, - entity = HttpEntity.Empty - ) + } + }, + pathPrefix("callback") { + pathEnd { + parameters("code", "state".?) { (code, state) => + onSuccess(github.getUserWithOauth2(code)) { user => + setSession(refreshable, usingCookies, session.addUser(user)) { + setNewCsrfToken(checkHeader) { ctx => + ctx.complete( + HttpResponse( + status = TemporaryRedirect, + headers = headers + .Location(Uri(state.getOrElse("/"))) :: Nil, + entity = HttpEntity.Empty ) - } + ) } } } } } - ) + } ) + ) + } diff --git a/server/src/main/scala/org/scastie/server/routes/ProgressRoutes.scala b/server/src/main/scala/org/scastie/server/routes/ProgressRoutes.scala index 55558591a..42b1bc2ac 100644 --- a/server/src/main/scala/org/scastie/server/routes/ProgressRoutes.scala +++ b/server/src/main/scala/org/scastie/server/routes/ProgressRoutes.scala @@ -1,6 +1,8 @@ package org.scastie.web.routes -import akka.NotUsed +import scala.concurrent.duration.DurationInt +import scala.concurrent.Future + import akka.actor.ActorRef import akka.http.scaladsl.coding.Coders.Gzip import akka.http.scaladsl.marshalling.sse.EventStreamMarshalling._ @@ -11,14 +13,13 @@ import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Route import akka.pattern.ask import akka.stream.scaladsl._ +import akka.NotUsed +import io.circe.syntax._ import org.scastie.api._ import org.scastie.balancer._ -import io.circe.syntax._ - -import scala.concurrent.Future -import scala.concurrent.duration.DurationInt class ProgressRoutes(progressActor: ActorRef) { + val routes: Route = encodeResponseWith(Gzip)( concat( snippetIdStart("progress-sse") { sid => @@ -28,14 +29,12 @@ class ProgressRoutes(progressActor: ActorRef) { } } }, - snippetIdStart("progress-ws")( - sid => handleWebSocketMessages(webSocket(sid)) - ) + snippetIdStart("progress-ws")(sid => handleWebSocketMessages(webSocket(sid))) ) ) private def progressSource( - snippetId: SnippetId + snippetId: SnippetId ): Source[SnippetProgress, NotUsed] = { Source .fromFuture((progressActor ? SubscribeProgress(snippetId))(1.second).mapTo[Source[SnippetProgress, NotUsed]]) @@ -55,8 +54,7 @@ class ProgressRoutes(progressActor: ActorRef) { case e => Future.failed(new Exception(e.toString)) } .via(flow) - .map( - progress => ws.TextMessage.Strict(progress.asJson.noSpaces) - ) + .map(progress => ws.TextMessage.Strict(progress.asJson.noSpaces)) } + } diff --git a/server/src/main/scala/org/scastie/server/routes/ScalaJsRoutes.scala b/server/src/main/scala/org/scastie/server/routes/ScalaJsRoutes.scala index 278568fa8..56e7237fa 100644 --- a/server/src/main/scala/org/scastie/server/routes/ScalaJsRoutes.scala +++ b/server/src/main/scala/org/scastie/server/routes/ScalaJsRoutes.scala @@ -1,50 +1,47 @@ package org.scastie.web.routes -import org.scastie.api._ - -import akka.util.Timeout +import scala.concurrent.duration.DurationInt -import akka.pattern.ask import akka.actor.{ActorRef, ActorSystem} +import akka.http.scaladsl.coding.Coders.Gzip import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Route -import akka.http.scaladsl.coding.Coders.Gzip - -import scala.concurrent.duration.DurationInt +import akka.pattern.ask +import akka.util.Timeout +import org.scastie.api._ //not used anymore -class ScalaJsRoutes(dispatchActor: ActorRef)(implicit system: ActorSystem) { +class ScalaJsRoutes(dispatchActor: ActorRef)( + implicit system: ActorSystem +) { import system.dispatcher implicit val timeout: Timeout = Timeout(1.seconds) - val routes: Route = - encodeResponseWith(Gzip)( - concat( - snippetIdEnd(Shared.scalaJsHttpPathPrefix, Js.targetFilename)( - sid => - complete( - (dispatchActor ? FetchScalaJs(sid)) - .mapTo[Option[FetchResultScalaJs]] - .map(_.map(_.content)) - ) - ), - snippetIdEnd(Shared.scalaJsHttpPathPrefix, Js.sourceFilename)( - sid => - complete( - (dispatchActor ? FetchScalaSource(sid)) - .mapTo[Option[FetchResultScalaSource]] - .map(_.map(_.content)) - ) - ), - snippetIdEnd(Shared.scalaJsHttpPathPrefix, Js.sourceMapFilename)( - sid => - complete( - (dispatchActor ? FetchScalaJsSourceMap(sid)) - .mapTo[Option[FetchResultScalaJsSourceMap]] - .map(_.map(_.content)) - ) + val routes: Route = encodeResponseWith(Gzip)( + concat( + snippetIdEnd(Shared.scalaJsHttpPathPrefix, Js.targetFilename)(sid => + complete( + (dispatchActor ? FetchScalaJs(sid)) + .mapTo[Option[FetchResultScalaJs]] + .map(_.map(_.content)) + ) + ), + snippetIdEnd(Shared.scalaJsHttpPathPrefix, Js.sourceFilename)(sid => + complete( + (dispatchActor ? FetchScalaSource(sid)) + .mapTo[Option[FetchResultScalaSource]] + .map(_.map(_.content)) + ) + ), + snippetIdEnd(Shared.scalaJsHttpPathPrefix, Js.sourceMapFilename)(sid => + complete( + (dispatchActor ? FetchScalaJsSourceMap(sid)) + .mapTo[Option[FetchResultScalaJsSourceMap]] + .map(_.map(_.content)) ) ) ) + ) + } diff --git a/server/src/main/scala/org/scastie/server/routes/ScalaLangRoutes.scala b/server/src/main/scala/org/scastie/server/routes/ScalaLangRoutes.scala index f2a78b77a..e1cd0f02e 100644 --- a/server/src/main/scala/org/scastie/server/routes/ScalaLangRoutes.scala +++ b/server/src/main/scala/org/scastie/server/routes/ScalaLangRoutes.scala @@ -1,26 +1,24 @@ package org.scastie.web.routes -import org.scastie.api._ -import org.scastie.web.oauth2._ - -import org.scastie.balancer._ +import scala.concurrent.duration.DurationInt -import akka.util.Timeout import akka.actor.{ActorRef, ActorSystem} - import akka.http.scaladsl.model.StatusCodes.Created -import akka.http.scaladsl.server.Route import akka.http.scaladsl.server.Directives._ - +import akka.http.scaladsl.server.Route import akka.pattern.ask - -import scala.concurrent.duration.DurationInt +import akka.util.Timeout +import org.scastie.api._ +import org.scastie.balancer._ +import org.scastie.web.oauth2._ // temporary route for the scala-lang frontpage class ScalaLangRoutes( dispatchActor: ActorRef, userDirectives: UserDirectives -)(implicit system: ActorSystem) { +)( + implicit system: ActorSystem +) { import system.dispatcher import userDirectives.optionalLogin diff --git a/server/src/main/scala/org/scastie/server/routes/StatusRoutes.scala b/server/src/main/scala/org/scastie/server/routes/StatusRoutes.scala index 5cb7121db..95d62a977 100644 --- a/server/src/main/scala/org/scastie/server/routes/StatusRoutes.scala +++ b/server/src/main/scala/org/scastie/server/routes/StatusRoutes.scala @@ -1,68 +1,64 @@ package org.scastie.web.routes -import akka.NotUsed +import scala.concurrent.{ExecutionContext, Future} +import scala.concurrent.duration.DurationInt + import akka.actor.ActorRef import akka.http.scaladsl.marshalling.sse.EventStreamMarshalling._ import akka.http.scaladsl.model._ import akka.http.scaladsl.model.sse.ServerSentEvent import akka.http.scaladsl.model.ws.TextMessage._ -import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.{Route, _} +import akka.http.scaladsl.server.Directives._ import akka.pattern.ask import akka.stream.scaladsl._ +import akka.NotUsed +import io.circe.syntax._ import org.scastie.api._ import org.scastie.balancer._ import org.scastie.web.oauth2.UserDirectives -import io.circe.syntax._ -import scala.concurrent.duration.DurationInt -import scala.concurrent.{ExecutionContext, Future} +class StatusRoutes(statusActor: ActorRef, userDirectives: UserDirectives)( + implicit ec: ExecutionContext +) { -class StatusRoutes(statusActor: ActorRef, userDirectives: UserDirectives)(implicit ec: ExecutionContext) { + val isAdminUser: Directive1[Boolean] = userDirectives.optionalLogin.map(user => user.exists(_.isAdmin)) - val isAdminUser: Directive1[Boolean] = - userDirectives.optionalLogin.map( - user => user.exists(_.isAdmin) - ) - - val routes: Route = - isAdminUser { isAdmin => - concat( - path("status-sse")( - complete( - statusSource(isAdmin).map { progress => - ServerSentEvent(progress.asJson.noSpaces) - } - ) - ), - path("status-ws")( - handleWebSocketMessages(webSocketProgress(isAdmin)) + val routes: Route = isAdminUser { isAdmin => + concat( + path("status-sse")( + complete( + statusSource(isAdmin).map { progress => + ServerSentEvent(progress.asJson.noSpaces) + } ) + ), + path("status-ws")( + handleWebSocketMessages(webSocketProgress(isAdmin)) ) - } + ) + } private def statusSource(isAdmin: Boolean) = { def hideTask(progress: StatusProgress): StatusProgress = if (isAdmin) progress - else - progress match { - case StatusProgress.Sbt(runners) => - // Hide the task Queue for non admin users, - // they will only see the runner count - StatusProgress.Sbt( - runners.map(_.copy(tasks = Vector())) - ) + else progress match { + case StatusProgress.Sbt(runners) => + // Hide the task Queue for non admin users, + // they will only see the runner count + StatusProgress.Sbt( + runners.map(_.copy(tasks = Vector())) + ) - case _ => - progress - } + case _ => progress + } Source .fromFuture((statusActor ? SubscribeStatus)(2.seconds).mapTo[Source[StatusProgress, NotUsed]]) .flatMapConcat(s => s.map(hideTask)) } private def webSocketProgress( - isAdmin: Boolean + isAdmin: Boolean ): Flow[ws.Message, ws.Message, _] = { def flow: Flow[String, StatusProgress, NotUsed] = { val in = Flow[String].to(Sink.ignore) @@ -76,8 +72,7 @@ class StatusRoutes(statusActor: ActorRef, userDirectives: UserDirectives)(implic case e => Future.failed(new Exception(e.toString)) } .via(flow) - .map( - progress => ws.TextMessage.Strict(progress.asJson.noSpaces) - ) + .map(progress => ws.TextMessage.Strict(progress.asJson.noSpaces)) } + } diff --git a/server/src/main/scala/org/scastie/server/routes/package.scala b/server/src/main/scala/org/scastie/server/routes/package.scala index 313fbe8da..775c3a7d2 100644 --- a/server/src/main/scala/org/scastie/server/routes/package.scala +++ b/server/src/main/scala/org/scastie/server/routes/package.scala @@ -1,49 +1,46 @@ package org.scastie.web -import org.scastie.api._ - -import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.{PathMatcher, Route} +import akka.http.scaladsl.server.Directives._ +import org.scastie.api._ package object routes { - def snippetIdStart(matcherStart: String)(f: SnippetId => Route): Route = - snippetIdBase( - matcherStart / _, - matcherStart / _ - )(f) - - def snippetId(f: SnippetId => Route): Route = - snippetIdBase( - p => p, - p => p - )(f) - - def snippetIdEnd(matcherStart: String, matcherEnd: String)(f: SnippetId => Route): Route = - snippetIdBase( - matcherStart / _ / matcherEnd, - matcherStart / _ / matcherEnd - )(f) - - def snippetIdExtension(extension: String)(f: SnippetId => Route): Route = - snippetIdBase( - _ ~ extension, - _ ~ extension - )(f) + + def snippetIdStart(matcherStart: String)(f: SnippetId => Route): Route = snippetIdBase( + matcherStart / _, + matcherStart / _ + )(f) + + def snippetId(f: SnippetId => Route): Route = snippetIdBase( + p => p, + p => p + )(f) + + def snippetIdEnd(matcherStart: String, matcherEnd: String)(f: SnippetId => Route): Route = snippetIdBase( + matcherStart / _ / matcherEnd, + matcherStart / _ / matcherEnd + )(f) + + def snippetIdExtension(extension: String)(f: SnippetId => Route): Route = snippetIdBase( + _ ~ extension, + _ ~ extension + )(f) private val uuidMatcher = PathMatcher("[A-Za-z0-9]{22}".r) private def snippetIdBase( - fp1: PathMatcher[Tuple1[String]] => PathMatcher[Tuple1[String]], - fp2: PathMatcher[(String, String, Option[Int])] => PathMatcher[ - (String, String, Option[Int]) - ] + fp1: PathMatcher[Tuple1[String]] => PathMatcher[Tuple1[String]], + fp2: PathMatcher[(String, String, Option[Int])] => PathMatcher[ + (String, String, Option[Int]) + ] )(f: SnippetId => Route): Route = { concat( path(fp1(uuidMatcher) ~ Slash.?)(uuid => f(SnippetId(uuid, None))), - path(fp2(Segment / uuidMatcher ~ (Slash ~ IntNumber).?) ~ Slash.?)( - (user, uuid, update) => f(SnippetId(uuid, Some(SnippetUserPart(user, update.getOrElse(0))))) + path(fp2(Segment / uuidMatcher ~ (Slash ~ IntNumber).?) ~ Slash.?)((user, uuid, update) => + f(SnippetId(uuid, Some(SnippetUserPart(user, update.getOrElse(0))))) ) ) } + } diff --git a/server/src/main/scala/org/scastie/server/utils/NightlyVersionFetcher.scala b/server/src/main/scala/org/scastie/server/utils/NightlyVersionFetcher.scala index 2702eb210..eff31eb25 100644 --- a/server/src/main/scala/org/scastie/server/utils/NightlyVersionFetcher.scala +++ b/server/src/main/scala/org/scastie/server/utils/NightlyVersionFetcher.scala @@ -1,10 +1,12 @@ package org.scastie.server.utils -import scala.concurrent.{Future, ExecutionContext} -import scala.io.Source import java.util.concurrent.ConcurrentHashMap +import scala.concurrent.{ExecutionContext, Future} +import scala.io.Source import scala.util.matching.Regex -import io.circe._, io.circe.parser._ + +import io.circe._ +import io.circe.parser._ object NightlyVersionFetcher { private val ttlMillis: Long = 60 * 60 * 1000 // 1 hour @@ -15,21 +17,24 @@ object NightlyVersionFetcher { "scala3" -> "https://repo1.maven.org/maven2/org/scala-lang/scala3-compiler_3/maven-metadata.xml" ) - def fetchRaw(api: String)(implicit ec: ExecutionContext): Future[String] = Future { + def fetchRaw(api: String)( + implicit ec: ExecutionContext + ): Future[String] = Future { val now = System.currentTimeMillis() val url = urls(api) val cached = Option(cache.get(api)) cached match { - case Some((data, timestamp)) if now - timestamp < ttlMillis => - data - case _ => + case Some((data, timestamp)) if now - timestamp < ttlMillis => data + case _ => val data = Source.fromURL(url).mkString cache.put(api, (data, now)) data } } - def getLatestScala2Nightly(prefix: String)(implicit ec: ExecutionContext): Future[Option[String]] = { + def getLatestScala2Nightly(prefix: String)( + implicit ec: ExecutionContext + ): Future[Option[String]] = { fetchRaw("scala2").map { data => val result = for { json <- parse(data) @@ -43,19 +48,20 @@ object NightlyVersionFetcher { result match { case Right(versions) if versions.nonEmpty => Some(versions.sorted.last) - case _ => None + case _ => None } } } - def getLatestScala3Nightly(implicit ec: ExecutionContext): Future[Option[String]] = { - val nightlyRegex: Regex = - raw"(.+-bin-\d{8}-\w{7}-NIGHTLY)".r + def getLatestScala3Nightly( + implicit ec: ExecutionContext + ): Future[Option[String]] = { + val nightlyRegex: Regex = raw"(.+-bin-\d{8}-\w{7}-NIGHTLY)".r fetchRaw("scala3").map { data => val versions = nightlyRegex.findAllMatchIn(data).map(_.group(1)).toList if (versions.nonEmpty) Some(versions.sorted.last) else None } } - -} \ No newline at end of file + +} diff --git a/server/src/test/scala/org/scastie/web/SnippetIdMatcherTests.scala b/server/src/test/scala/org/scastie/web/SnippetIdMatcherTests.scala index e7e9e8dc5..c48b65823 100644 --- a/server/src/test/scala/org/scastie/web/SnippetIdMatcherTests.scala +++ b/server/src/test/scala/org/scastie/web/SnippetIdMatcherTests.scala @@ -3,21 +3,19 @@ package org.scastie.web.routes import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Route import akka.http.scaladsl.testkit.ScalatestRouteTest -import org.scastie.api.{SnippetId, SnippetUserPart} -import org.scalatest.funsuite.AnyFunSuite import de.heikoseeberger.akkahttpcirce.FailFastCirceSupport - +import org.scalatest.funsuite.AnyFunSuite +import org.scastie.api.{SnippetId, SnippetUserPart} class SnippetIdMatcherTests extends AnyFunSuite with ScalatestRouteTest { import FailFastCirceSupport._ def testRoute(snippetIdRoute: Route, f1: String => String, f2: String => String, checkEnd: Boolean = true): Unit = { - val expectedBase = - SnippetId( - "GIbgJuUFSKaVzLDGK4kxdw", - None - ) + val expectedBase = SnippetId( + "GIbgJuUFSKaVzLDGK4kxdw", + None + ) println(f1("/GIbgJuUFSKaVzLDGK4kxdw")) Get(f1("/GIbgJuUFSKaVzLDGK4kxdw")) ~> snippetIdRoute ~> check { @@ -32,11 +30,10 @@ class SnippetIdMatcherTests extends AnyFunSuite with ScalatestRouteTest { } } - val expectedUser = - SnippetId( - "GIbgJuUFSKaVzLDGK4kxdw", - Some(SnippetUserPart("MasseGuillaume", 0)) - ) + val expectedUser = SnippetId( + "GIbgJuUFSKaVzLDGK4kxdw", + Some(SnippetUserPart("MasseGuillaume", 0)) + ) Get(f1("/MasseGuillaume/GIbgJuUFSKaVzLDGK4kxdw")) ~> snippetIdRoute ~> check { val obtained = responseAs[SnippetId] @@ -50,11 +47,10 @@ class SnippetIdMatcherTests extends AnyFunSuite with ScalatestRouteTest { } } - val expectedFull = - SnippetId( - "GIbgJuUFSKaVzLDGK4kxdw", - Some(SnippetUserPart("MasseGuillaume", 2)) - ) + val expectedFull = SnippetId( + "GIbgJuUFSKaVzLDGK4kxdw", + Some(SnippetUserPart("MasseGuillaume", 2)) + ) Get(f1("/MasseGuillaume/GIbgJuUFSKaVzLDGK4kxdw/2")) ~> snippetIdRoute ~> check { val obtained = responseAs[SnippetId] @@ -70,10 +66,9 @@ class SnippetIdMatcherTests extends AnyFunSuite with ScalatestRouteTest { } test("snippetId") { - val snippetIdRoute = - get( - snippetId(sid => complete(sid)) - ) + val snippetIdRoute = get( + snippetId(sid => complete(sid)) + ) testRoute( snippetIdRoute, @@ -85,10 +80,9 @@ class SnippetIdMatcherTests extends AnyFunSuite with ScalatestRouteTest { test("snippetIdStart") { val start = "snippets" - val snippetIdRoute = - get( - snippetIdStart(start)(sid => complete(sid)) - ) + val snippetIdRoute = get( + snippetIdStart(start)(sid => complete(sid)) + ) testRoute( snippetIdRoute, @@ -101,10 +95,9 @@ class SnippetIdMatcherTests extends AnyFunSuite with ScalatestRouteTest { val start = "api" val end = "foo" - val snippetIdRoute = - get( - snippetIdEnd(start, end)(sid => complete(sid)) - ) + val snippetIdRoute = get( + snippetIdEnd(start, end)(sid => complete(sid)) + ) testRoute( snippetIdRoute, @@ -116,10 +109,9 @@ class SnippetIdMatcherTests extends AnyFunSuite with ScalatestRouteTest { test("snippetIdExtension") { val extension = ".js" - val snippetIdRoute = - get( - snippetIdExtension(extension)(sid => complete(sid)) - ) + val snippetIdRoute = get( + snippetIdExtension(extension)(sid => complete(sid)) + ) testRoute( snippetIdRoute, diff --git a/storage/src/main/scala/org/scastie/storage/OldScastieConverter.scala b/storage/src/main/scala/org/scastie/storage/OldScastieConverter.scala index 1761e4e76..9b0008787 100644 --- a/storage/src/main/scala/org/scastie/storage/OldScastieConverter.scala +++ b/storage/src/main/scala/org/scastie/storage/OldScastieConverter.scala @@ -3,6 +3,7 @@ package org.scastie.storage import org.scastie.api._ object OldScastieConverter { + private def convertLine(line: String): Converter => Converter = { converter => val sv = "scalaVersion := \"" @@ -16,11 +17,9 @@ object OldScastieConverter { case """scalaOrganization in ThisBuild := "org.typelevel"""" => converter.setTargetType(ScalaTargetType.Typelevel) - case "coursier.CoursierPlugin.projectSettings" => - converter + case "coursier.CoursierPlugin.projectSettings" => converter - case _ => - converter.appendSbt(line) + case _ => converter.appendSbt(line) } } } @@ -28,11 +27,10 @@ object OldScastieConverter { def convertOldOutput(content: String): List[SnippetProgress] = { content .split("\n") - .map( - line => - SnippetProgress.default.copy( - userOutput = Some(ProcessOutput(line, ProcessOutputType.StdOut, None)), - isDone = true + .map(line => + SnippetProgress.default.copy( + userOutput = Some(ProcessOutput(line, ProcessOutputType.StdOut, None)), + isDone = true ) ) .toList @@ -51,11 +49,9 @@ object OldScastieConverter { val sbtConfig = content.slice(start, start + blockEndPos - start) val code = content.drop(blockEndPos + blockEnd.length).trim - val converterFn = - sbtConfig.split("\n").foldLeft(Converter.nil) { - case (converter, line) => - convertLine(line)(converter) - } + val converterFn = sbtConfig.split("\n").foldLeft(Converter.nil) { case (converter, line) => + convertLine(line)(converter) + } converterFn(SbtInputs.default).copyBaseInput(code = code) } else { @@ -64,12 +60,13 @@ object OldScastieConverter { } private object Converter { - def nil: Converter = - Converter( - scalaVersion = None, - targetType = None, - sbtExtra = "" - ) + + def nil: Converter = Converter( + scalaVersion = None, + targetType = None, + sbtExtra = "" + ) + } private case class Converter( @@ -77,44 +74,40 @@ object OldScastieConverter { targetType: Option[ScalaTargetType], sbtExtra: String ) { - def appendSbt(in: String): Converter = - copy(sbtExtra = sbtExtra + "\n" + in) + def appendSbt(in: String): Converter = copy(sbtExtra = sbtExtra + "\n" + in) - def setTargetType(targetType0: ScalaTargetType): Converter = - copy(targetType = Some(targetType0)) + def setTargetType(targetType0: ScalaTargetType): Converter = copy(targetType = Some(targetType0)) def apply(inputs: BaseInputs): BaseInputs = { - val scalaTarget = - targetType match { - case Some(ScalaTargetType.Scala3) => - Scala3.default - - case Some(ScalaTargetType.Typelevel) => - scalaVersion - .map(sv => Typelevel(sv)) - .getOrElse( - Typelevel.default - ) - - case _ => - scalaVersion - .map(sv => Scala2(sv)) - .getOrElse( - Scala2.default - ) - } + val scalaTarget = targetType match { + case Some(ScalaTargetType.Scala3) => Scala3.default + + case Some(ScalaTargetType.Typelevel) => scalaVersion + .map(sv => Typelevel(sv)) + .getOrElse( + Typelevel.default + ) + + case _ => scalaVersion + .map(sv => Scala2(sv)) + .getOrElse( + Scala2.default + ) + } inputs match { case sbtInputs: SbtInputs => sbtInputs.copy( - target = scalaTarget, - sbtConfigExtra = sbtExtra.trim, - isWorksheetMode = false - ) + target = scalaTarget, + sbtConfigExtra = sbtExtra.trim, + isWorksheetMode = false + ) case _ => inputs.copyBaseInput( - isWorksheetMode = false, - ) + isWorksheetMode = false + ) } } + } + } diff --git a/storage/src/main/scala/org/scastie/storage/SnippetsContainer.scala b/storage/src/main/scala/org/scastie/storage/SnippetsContainer.scala index 381537b83..67258d32d 100644 --- a/storage/src/main/scala/org/scastie/storage/SnippetsContainer.scala +++ b/storage/src/main/scala/org/scastie/storage/SnippetsContainer.scala @@ -1,28 +1,26 @@ package org.scastie.storage -import org.scastie.api._ -import org.scastie.instrumentation._ -import org.scastie.util.Base64UUID - -import net.lingala.zip4j.ZipFile -import net.lingala.zip4j.model.ZipParameters - import java.nio.file.{Files, Path, Paths} import scala.concurrent.{ExecutionContext, Future} +import net.lingala.zip4j.model.ZipParameters +import net.lingala.zip4j.ZipFile +import org.scastie.api._ +import org.scastie.instrumentation._ +import org.scastie.util.Base64UUID trait SnippetsContainer { protected implicit val ec: ExecutionContext def appendOutput(progress: SnippetProgress): Future[Unit] + def deleteAll(snippetId: SnippetId): Future[Boolean] = { def deleteUpdate(update: Int): Future[Boolean] = { val updateSnippetId = snippetId.copy(user = snippetId.user.map(_.copy(update = update))) for { read <- readSnippet(updateSnippetId) result <- read match { - case Some(_) => - for { + case Some(_) => for { result <- delete(updateSnippetId) resultNext <- deleteUpdate(update + 1) } yield result || resultNext @@ -32,22 +30,28 @@ trait SnippetsContainer { } deleteUpdate(0) } + protected def delete(snippetId: SnippetId): Future[Boolean] + def removeUserSnippets(user: UserLogin): Future[Boolean] = { listSnippets(user).flatMap(snippets => { - Future.sequence( - snippets - .map(snippet => deleteAll(snippet.snippetId))) - .map(_.fold(true)(_ && _) - ) + Future + .sequence( + snippets + .map(snippet => deleteAll(snippet.snippetId)) + ) + .map(_.fold(true)(_ && _)) }) } + def listSnippets(user: UserLogin): Future[List[SnippetSummary]] def readOldSnippet(id: Int): Future[Option[FetchResult]] def readScalaJs(snippetId: SnippetId): Future[Option[FetchResultScalaJs]] + def readScalaJsSourceMap( - snippetId: SnippetId + snippetId: SnippetId ): Future[Option[FetchResultScalaJsSourceMap]] + def readSnippet(snippetId: SnippetId): Future[Option[FetchResult]] protected def insert(snippetId: SnippetId, inputs: BaseInputs): Future[Unit] protected def hideFromUserProfile(snippetId: SnippetId): Future[Unit] @@ -64,8 +68,7 @@ trait SnippetsContainer { final def update(snippetId: SnippetId, inputs: BaseInputs): Future[Option[SnippetId]] = { updateSnippetId(snippetId).flatMap { - case Some(nextSnippetId) => - for { + case Some(nextSnippetId) => for { r <- insert0(nextSnippetId, inputs.copyBaseInput(forked = Some(snippetId), isShowingInUserProfile = true)) _ <- hideFromUserProfile(snippetId) } yield Some(r) @@ -77,24 +80,20 @@ trait SnippetsContainer { create(inputs.copyBaseInput(forked = Some(snippetId), isShowingInUserProfile = true), user) final def readScalaSource( - snippetId: SnippetId - ): Future[Option[FetchResultScalaSource]] = - readSnippet(snippetId).map( - _.flatMap( - snippet => - Instrument(snippet.inputs.code, snippet.inputs.target) match { - case Right(InstrumentationSuccess(instrumentedCode, _)) => - Some(FetchResultScalaSource(instrumentedCode)) - case _ => None - } - ) + snippetId: SnippetId + ): Future[Option[FetchResultScalaSource]] = readSnippet(snippetId).map( + _.flatMap(snippet => + Instrument(snippet.inputs.code, snippet.inputs.target) match { + case Right(InstrumentationSuccess(instrumentedCode, _)) => Some(FetchResultScalaSource(instrumentedCode)) + case _ => None + } ) + ) - final def downloadSnippet(snippetId: SnippetId): Future[Option[Path]] = - readSnippet(snippetId).map(_.flatMap { - case FetchResult(sbtInputs: SbtInputs, _) => Option(asZip(snippetId)(sbtInputs)) - case _ => None - }) + final def downloadSnippet(snippetId: SnippetId): Future[Option[Path]] = readSnippet(snippetId).map(_.flatMap { + case FetchResult(sbtInputs: SbtInputs, _) => Option(asZip(snippetId)(sbtInputs)) + case _ => None + }) protected final def newSnippetId(user: Option[UserLogin]): SnippetId = { val uuid = Base64UUID.create @@ -131,11 +130,17 @@ trait SnippetsContainer { Files.createDirectories(projectDir) val buildFile = projectDir.resolve("build.sbt") - Files.write(buildFile, inputs.sbtConfig.linesIterator.filterNot(_.contains("org.scastie")).mkString("\n").getBytes()) + Files.write( + buildFile, + inputs.sbtConfig.linesIterator.filterNot(_.contains("org.scastie")).mkString("\n").getBytes() + ) val projectFile = projectDir.resolve("project/plugins.sbt") Files.createDirectories(projectFile.getParent) - Files.write(projectFile, inputs.sbtPluginsConfig.linesIterator.filterNot(_.contains("org.scastie")).mkString("\n").getBytes()) + Files.write( + projectFile, + inputs.sbtPluginsConfig.linesIterator.filterNot(_.contains("org.scastie")).mkString("\n").getBytes() + ) val codeFile = projectDir.resolve(s"src/main/scala/main.${if (inputs.isWorksheetMode) "sc" else "scala"}") Files.createDirectories(codeFile.getParent) diff --git a/storage/src/main/scala/org/scastie/storage/filesystem/FilesystemContainer.scala b/storage/src/main/scala/org/scastie/storage/filesystem/FilesystemContainer.scala index 740587aff..82d1fc75a 100644 --- a/storage/src/main/scala/org/scastie/storage/filesystem/FilesystemContainer.scala +++ b/storage/src/main/scala/org/scastie/storage/filesystem/FilesystemContainer.scala @@ -4,5 +4,6 @@ import java.nio.file._ import scala.concurrent.ExecutionContext class FilesystemContainer(val root: Path, val oldRoot: Path)( - implicit val ec: ExecutionContext -) extends FilesystemUsersContainer with FilesystemSnippetsContainer + implicit val ec: ExecutionContext +) extends FilesystemUsersContainer + with FilesystemSnippetsContainer diff --git a/storage/src/main/scala/org/scastie/storage/filesystem/FilesystemSnippetsContainer.scala b/storage/src/main/scala/org/scastie/storage/filesystem/FilesystemSnippetsContainer.scala index e0ecd12a2..0ebf3bff7 100644 --- a/storage/src/main/scala/org/scastie/storage/filesystem/FilesystemSnippetsContainer.scala +++ b/storage/src/main/scala/org/scastie/storage/filesystem/FilesystemSnippetsContainer.scala @@ -1,20 +1,18 @@ package org.scastie.storage.filesystem -import org.scastie.api._ -import org.scastie.storage.OldScastieConverter -import org.scastie.storage.SnippetsContainer -import org.scastie.storage.UserLogin -import io.circe._ -import io.circe.syntax._ -import io.circe.parser._ - import java.io.IOException import java.nio.file._ import scala.concurrent.Future +import io.circe._ +import io.circe.parser._ +import io.circe.syntax._ +import org.scastie.api._ +import org.scastie.storage.OldScastieConverter +import org.scastie.storage.SnippetsContainer +import org.scastie.storage.UserLogin import System.{lineSeparator => nl} - trait FilesystemSnippetsContainer extends SnippetsContainer with GenericFilesystemContainer { val root: Path val oldRoot: Path @@ -27,17 +25,14 @@ trait FilesystemSnippetsContainer extends SnippetsContainer with GenericFilesyst case _ => () } - progress.snippetId.foreach( - sid => append(outputsFile(sid), progress.asJson.noSpaces + nl) - ) + progress.snippetId.foreach(sid => append(outputsFile(sid), progress.asJson.noSpaces + nl)) } def delete(snippetId: SnippetId): Future[Boolean] = { def rootDir(snippetId: SnippetId): Path = { snippetId.user match { - case Some(SnippetUserPart(login, _)) => - root.resolve(login) - case _ => root.resolve(anonFolder) + case Some(SnippetUserPart(login, _)) => root.resolve(login) + case _ => root.resolve(anonFolder) } } @@ -106,8 +101,7 @@ trait FilesystemSnippetsContainer extends SnippetsContainer with GenericFilesyst updates .flatMap { update => - val snippetId = - SnippetId(uuid, Some(SnippetUserPart(user.login, update))) + val snippetId = SnippetId(uuid, Some(SnippetUserPart(user.login, update))) readInputs(snippetId) match { case Some(inputs) => if (inputs.isShowingInUserProfile) { @@ -131,10 +125,9 @@ trait FilesystemSnippetsContainer extends SnippetsContainer with GenericFilesyst def readOldSnippet(id: Int): Future[Option[FetchResult]] = { - def oldPath(id: Int): Path = - oldRoot - .resolve("paste%20d".format(id).replaceAll(" ", "0")) - .resolve("src/main/scala/") + def oldPath(id: Int): Path = oldRoot + .resolve("paste%20d".format(id).replaceAll(" ", "0")) + .resolve("src/main/scala/") def readOldInputs(id: Int): Option[BaseInputs] = { slurp(oldPath(id).resolve("test.scala")) @@ -147,46 +140,39 @@ trait FilesystemSnippetsContainer extends SnippetsContainer with GenericFilesyst } Future { - readOldInputs(id).map( - inputs => FetchResult.create(inputs, readOldOutputs(id).getOrElse(Nil)) - ) + readOldInputs(id).map(inputs => FetchResult.create(inputs, readOldOutputs(id).getOrElse(Nil))) } } - def readScalaJs(snippetId: SnippetId): Future[Option[FetchResultScalaJs]] = - Future { - slurp(scalaJsFile(snippetId)).map(content => FetchResultScalaJs(content)) - } + def readScalaJs(snippetId: SnippetId): Future[Option[FetchResultScalaJs]] = Future { + slurp(scalaJsFile(snippetId)).map(content => FetchResultScalaJs(content)) + } def readScalaJsSourceMap( - snippetId: SnippetId + snippetId: SnippetId ): Future[Option[FetchResultScalaJsSourceMap]] = Future { slurp(scalaJsSourceMapFile(snippetId)) .map(content => FetchResultScalaJsSourceMap(content)) } def readSnippet(snippetId: SnippetId): Future[Option[FetchResult]] = Future { - readInputs(snippetId).map( - inputs => FetchResult.create(inputs, readOutputs(snippetId).getOrElse(Nil)) - ) + readInputs(snippetId).map(inputs => FetchResult.create(inputs, readOutputs(snippetId).getOrElse(Nil))) } - protected def insert(snippetId: SnippetId, inputs: BaseInputs): Future[Unit] = - Future { - val adjustedInputs = inputs match { - case sbtInputs: SbtInputs => sbtInputs.withSavedConfig - case _ => inputs - } - write(inputsFile(snippetId), adjustedInputs.asJson.noSpaces) + protected def insert(snippetId: SnippetId, inputs: BaseInputs): Future[Unit] = Future { + val adjustedInputs = inputs match { + case sbtInputs: SbtInputs => sbtInputs.withSavedConfig + case _ => inputs } + write(inputsFile(snippetId), adjustedInputs.asJson.noSpaces) + } - override protected def hideFromUserProfile(snippetId: SnippetId): Future[Unit] = - for { - old <- readSnippet(snippetId) - _ <- Future.traverse(old.toList) { old => - insert(snippetId, old.inputs.copyBaseInput(isShowingInUserProfile = false)) - } - } yield () + override protected def hideFromUserProfile(snippetId: SnippetId): Future[Unit] = for { + old <- readSnippet(snippetId) + _ <- Future.traverse(old.toList) { old => + insert(snippetId, old.inputs.copyBaseInput(isShowingInUserProfile = false)) + } + } yield () private val anonFolder = "_anonymous_" private val inputFileName = "input3.json" @@ -213,42 +199,40 @@ trait FilesystemSnippetsContainer extends SnippetsContainer with GenericFilesyst private def snippetFile(snippetId: SnippetId, fileName: String): Path = { if (!Files.exists(root)) Files.createDirectory(root) - val baseDirectory = - snippetId.user match { - case Some(SnippetUserPart(login, update)) => - val userFolder = root.resolve(login) - if (!Files.exists(userFolder)) Files.createDirectory(userFolder) + val baseDirectory = snippetId.user match { + case Some(SnippetUserPart(login, update)) => + val userFolder = root.resolve(login) + if (!Files.exists(userFolder)) Files.createDirectory(userFolder) - val base = userFolder.resolve(snippetId.base64UUID) - if (!Files.exists(base)) Files.createDirectory(base) + val base = userFolder.resolve(snippetId.base64UUID) + if (!Files.exists(base)) Files.createDirectory(base) - val baseVersion = base.resolve(update.toString) - if (!Files.exists(baseVersion)) Files.createDirectory(baseVersion) + val baseVersion = base.resolve(update.toString) + if (!Files.exists(baseVersion)) Files.createDirectory(baseVersion) - baseVersion - case None => - val anon = root.resolve(anonFolder) - if (!Files.exists(anon)) Files.createDirectory(anon) + baseVersion + case None => + val anon = root.resolve(anonFolder) + if (!Files.exists(anon)) Files.createDirectory(anon) - val base = anon.resolve(snippetId.base64UUID) - if (!Files.exists(base)) Files.createDirectory(base) - base - } + val base = anon.resolve(snippetId.base64UUID) + if (!Files.exists(base)) Files.createDirectory(base) + base + } baseDirectory.resolve(Paths.get(fileName)) } private def readInputs(snippetId: SnippetId): Option[BaseInputs] = { slurp(inputsFile(snippetId)) - .map( - content => - decode[BaseInputs](content) - .fold(e => sys.error(e.toString + s" for ${snippetId} $content"), identity) + .map(content => + decode[BaseInputs](content) + .fold(e => sys.error(e.toString + s" for ${snippetId} $content"), identity) ) } private def readOutputs( - snippetId: SnippetId + snippetId: SnippetId ): Option[List[SnippetProgress]] = { slurp(outputsFile(snippetId)).map { _.linesIterator @@ -261,7 +245,6 @@ trait FilesystemSnippetsContainer extends SnippetsContainer with GenericFilesyst } } - private def deleteEmptyDirectories(base: Path): Unit = { def dirIsEmpty(dir: Path): Boolean = { val ds = Files.newDirectoryStream(dir) @@ -284,4 +267,5 @@ trait FilesystemSnippetsContainer extends SnippetsContainer with GenericFilesyst () } + } diff --git a/storage/src/main/scala/org/scastie/storage/filesystem/FilesystemUsersContainer.scala b/storage/src/main/scala/org/scastie/storage/filesystem/FilesystemUsersContainer.scala index 5a6a3025c..05060f446 100644 --- a/storage/src/main/scala/org/scastie/storage/filesystem/FilesystemUsersContainer.scala +++ b/storage/src/main/scala/org/scastie/storage/filesystem/FilesystemUsersContainer.scala @@ -1,20 +1,21 @@ package org.scastie.storage.filesystem -import org.scastie.storage.PolicyAcceptance -import org.scastie.storage.UserLogin -import org.scastie.storage.UsersContainer -import io.circe._ -import io.circe.syntax._ -import io.circe.parser._ - import java.nio.file._ import scala.concurrent.Future import scala.util.Try +import io.circe._ +import io.circe.parser._ +import io.circe.syntax._ +import org.scastie.storage.PolicyAcceptance +import org.scastie.storage.UserLogin +import org.scastie.storage.UsersContainer + trait FilesystemUsersContainer extends UsersContainer with GenericFilesystemContainer { val root: Path def addNewUser(user: UserLogin): Future[Boolean] = setPrivacyPolicyResponse(user, true) + def deleteUser(user: UserLogin): Future[Boolean] = Future { val userDir = root.resolve(user.login) val privacyPolicyFile = userDir.resolve("policy-acceptance.json") @@ -35,18 +36,19 @@ trait FilesystemUsersContainer extends UsersContainer with GenericFilesystemCont }.isSuccess } - def getPrivacyPolicyResponse(user: UserLogin): Future[Boolean] = Future { val userDir = root.resolve(user.login) val privacyPolicyFile = userDir.resolve("policy-acceptance.json") - val maybePrivacyPolicy = if (Files.exists(privacyPolicyFile)) { - val response = new String(Files.readAllBytes(privacyPolicyFile)) - decode[PolicyAcceptance](response).toOption - } else { - None - } + val maybePrivacyPolicy = + if (Files.exists(privacyPolicyFile)) { + val response = new String(Files.readAllBytes(privacyPolicyFile)) + decode[PolicyAcceptance](response).toOption + } else { + None + } maybePrivacyPolicy.map(_.acceptedPrivacyPolicy).getOrElse(true) } + } diff --git a/storage/src/main/scala/org/scastie/storage/filesystem/GenericFilesystemContainer.scala b/storage/src/main/scala/org/scastie/storage/filesystem/GenericFilesystemContainer.scala index 2253be8cb..087570b4a 100644 --- a/storage/src/main/scala/org/scastie/storage/filesystem/GenericFilesystemContainer.scala +++ b/storage/src/main/scala/org/scastie/storage/filesystem/GenericFilesystemContainer.scala @@ -16,4 +16,5 @@ trait GenericFilesystemContainer { if (Files.exists(src)) Some(new String(Files.readAllBytes(src))) else None } + } diff --git a/storage/src/main/scala/org/scastie/storage/inmemory/InMemoryContainer.scala b/storage/src/main/scala/org/scastie/storage/inmemory/InMemoryContainer.scala index 0f6682797..8bc61feea 100644 --- a/storage/src/main/scala/org/scastie/storage/inmemory/InMemoryContainer.scala +++ b/storage/src/main/scala/org/scastie/storage/inmemory/InMemoryContainer.scala @@ -2,6 +2,7 @@ package org.scastie.storage.inmemory import scala.concurrent.ExecutionContext -class InMemoryContainer(implicit val ec: ExecutionContext) extends InMemoryUsersContainer with InMemorySnippetsContainer { - -} +class InMemoryContainer( + implicit val ec: ExecutionContext +) extends InMemoryUsersContainer + with InMemorySnippetsContainer {} diff --git a/storage/src/main/scala/org/scastie/storage/inmemory/InMemorySnippetsContainer.scala b/storage/src/main/scala/org/scastie/storage/inmemory/InMemorySnippetsContainer.scala index 5bb1c8682..6135ca41a 100644 --- a/storage/src/main/scala/org/scastie/storage/inmemory/InMemorySnippetsContainer.scala +++ b/storage/src/main/scala/org/scastie/storage/inmemory/InMemorySnippetsContainer.scala @@ -1,15 +1,13 @@ package org.scastie.storage.inmemory -import org.scastie.api._ -import org.scastie.storage.SnippetsContainer -import org.scastie.storage.UserLogin - import scala.collection.mutable import scala.concurrent.Future +import org.scastie.api._ +import org.scastie.storage.SnippetsContainer +import org.scastie.storage.UserLogin import System.{lineSeparator => nl} - trait InMemorySnippetsContainer extends SnippetsContainer { private val snippets = mutable.Map[SnippetId, Storage]() @@ -24,10 +22,9 @@ trait InMemorySnippetsContainer extends SnippetsContainer { ) def appendOutput(progress: SnippetProgress): Future[Unit] = Future { - progress.snippetId.foreach( - id => snippets.get(id).foreach(storage => storage.progresses += progress) - ) + progress.snippetId.foreach(id => snippets.get(id).foreach(storage => storage.progresses += progress)) } + def delete(snippetId: SnippetId): Future[Boolean] = Future { val found = snippets.contains(snippetId) snippets -= snippetId @@ -48,13 +45,12 @@ trait InMemorySnippetsContainer extends SnippetsContainer { .toList } - def readScalaJs(snippetId: SnippetId): Future[Option[FetchResultScalaJs]] = - Future { - snippets.get(snippetId).map(m => FetchResultScalaJs(m.scalaJsContent)) - } + def readScalaJs(snippetId: SnippetId): Future[Option[FetchResultScalaJs]] = Future { + snippets.get(snippetId).map(m => FetchResultScalaJs(m.scalaJsContent)) + } def readScalaJsSourceMap( - snippetId: SnippetId + snippetId: SnippetId ): Future[Option[FetchResultScalaJsSourceMap]] = Future { snippets .get(snippetId) @@ -70,7 +66,7 @@ trait InMemorySnippetsContainer extends SnippetsContainer { protected def insert(snippetId: SnippetId, inputs: BaseInputs): Future[Unit] = { val adjustedInputs = inputs match { case sbtInputs: SbtInputs => sbtInputs.withSavedConfig - case _ => inputs + case _ => inputs } Future { snippets.update(snippetId, Storage(snippetId, adjustedInputs)) @@ -82,4 +78,5 @@ trait InMemorySnippetsContainer extends SnippetsContainer { old <- snippets.get(snippetId) } yield snippets.update(snippetId, old.copy(inputs = old.inputs.copyBaseInput(isShowingInUserProfile = false))) } + } diff --git a/storage/src/main/scala/org/scastie/storage/inmemory/InMemoryUsersContainer.scala b/storage/src/main/scala/org/scastie/storage/inmemory/InMemoryUsersContainer.scala index 9ff454aad..c42296bcc 100644 --- a/storage/src/main/scala/org/scastie/storage/inmemory/InMemoryUsersContainer.scala +++ b/storage/src/main/scala/org/scastie/storage/inmemory/InMemoryUsersContainer.scala @@ -1,12 +1,12 @@ package org.scastie.storage.inmemory +import scala.collection.mutable +import scala.concurrent.Future + import org.scastie.storage.PolicyAcceptance import org.scastie.storage.UserLogin import org.scastie.storage.UsersContainer -import scala.collection.mutable -import scala.concurrent.Future - trait InMemoryUsersContainer extends UsersContainer { val users: mutable.Set[PolicyAcceptance] = mutable.Set[PolicyAcceptance]() @@ -29,4 +29,5 @@ trait InMemoryUsersContainer extends UsersContainer { // All user containers will be removed after said period of time maybeUser.map(_.acceptedPrivacyPolicy).getOrElse(true) } + } diff --git a/storage/src/main/scala/org/scastie/storage/mongodb/GenericMongoContainer.scala b/storage/src/main/scala/org/scastie/storage/mongodb/GenericMongoContainer.scala index 4495f1fe4..3c071c518 100644 --- a/storage/src/main/scala/org/scastie/storage/mongodb/GenericMongoContainer.scala +++ b/storage/src/main/scala/org/scastie/storage/mongodb/GenericMongoContainer.scala @@ -1,9 +1,9 @@ package org.scastie.storage.mongodb -import org.mongodb.scala._ import io.circe._ import io.circe.parser._ import io.circe.syntax._ +import org.mongodb.scala._ trait GenericMongoContainer { val mongoUri: String @@ -25,4 +25,5 @@ trait GenericMongoContainer { ): Option[T] = { decode[T](obj.toJson()).toOption } + } diff --git a/storage/src/main/scala/org/scastie/storage/mongodb/MongoDBContainer.scala b/storage/src/main/scala/org/scastie/storage/mongodb/MongoDBContainer.scala index 4e3a31fcb..0f0b762b5 100644 --- a/storage/src/main/scala/org/scastie/storage/mongodb/MongoDBContainer.scala +++ b/storage/src/main/scala/org/scastie/storage/mongodb/MongoDBContainer.scala @@ -1,27 +1,28 @@ package org.scastie.storage.mongodb +import scala.concurrent.ExecutionContext + import com.typesafe.config.ConfigFactory import org.mongodb.scala._ -import scala.concurrent.ExecutionContext - class MongoDBContainer(defaultConfig: Boolean = false)( - implicit val ec: ExecutionContext -) extends MongoDBUsersContainer with MongoDBSnippetsContainer { + implicit val ec: ExecutionContext +) extends MongoDBUsersContainer + with MongoDBSnippetsContainer { val mongoUri = { if (defaultConfig) s"mongodb://localhost:27017/scastie" else { - val config = ConfigFactory.load().getConfig("scastie.mongodb") - val user = config.getString("user") - val password = config.getString("password") + val config = ConfigFactory.load().getConfig("scastie.mongodb") + val user = config.getString("user") + val password = config.getString("password") val databaseName = config.getString("database") - val host = config.getString("host") - val port = config.getInt("port") + val host = config.getString("host") + val port = config.getInt("port") s"mongodb://$user:$password@$host:$port/$databaseName" } } protected val client: MongoClient = MongoClient(mongoUri) - val database: MongoDatabase = client.getDatabase("snippets") + val database: MongoDatabase = client.getDatabase("snippets") } diff --git a/storage/src/main/scala/org/scastie/storage/mongodb/MongoDBSnippetsContainer.scala b/storage/src/main/scala/org/scastie/storage/mongodb/MongoDBSnippetsContainer.scala index 793eab506..7f39d2715 100644 --- a/storage/src/main/scala/org/scastie/storage/mongodb/MongoDBSnippetsContainer.scala +++ b/storage/src/main/scala/org/scastie/storage/mongodb/MongoDBSnippetsContainer.scala @@ -1,39 +1,46 @@ package org.scastie.storage.mongodb +import java.lang.System.{lineSeparator => nl} +import scala.concurrent.duration._ +import scala.concurrent.Await +import scala.concurrent.Future + import com.mongodb.client.result.UpdateResult import org.mongodb.scala._ import org.mongodb.scala.bson.BsonArray +import org.mongodb.scala.model._ import org.mongodb.scala.model.Filters._ import org.mongodb.scala.model.Projections._ import org.mongodb.scala.model.Updates._ -import org.mongodb.scala.model._ import org.scastie.api._ import org.scastie.storage._ -import java.lang.System.{lineSeparator => nl} -import scala.concurrent.Await -import scala.concurrent.Future -import scala.concurrent.duration._ - - trait MongoDBSnippetsContainer extends SnippetsContainer with GenericMongoContainer { + lazy val snippets = { val db = database.getCollection[Document]("snippets") - Await.result(db.createIndex(Indexes.ascending("simpleSnippetId", "oldId"), IndexOptions().unique(true)).head(), Duration.Inf) - Await.result(Future.sequence(Seq( - Indexes.hashed("simpleSnippetId"), - Indexes.hashed("oldId"), - Indexes.hashed("user"), - Indexes.hashed("snippetId.user.login"), - Indexes.hashed("inputs.isShowingInUserProfile"), - Indexes.hashed("time") - ).map(db.createIndex(_).head())), Duration.Inf) + Await.result( + db.createIndex(Indexes.ascending("simpleSnippetId", "oldId"), IndexOptions().unique(true)).head(), + Duration.Inf + ) + Await.result( + Future.sequence( + Seq( + Indexes.hashed("simpleSnippetId"), + Indexes.hashed("oldId"), + Indexes.hashed("user"), + Indexes.hashed("snippetId.user.login"), + Indexes.hashed("inputs.isShowingInUserProfile"), + Indexes.hashed("time") + ).map(db.createIndex(_).head()) + ), + Duration.Inf + ) db } - def toMongoSnippet(snippetId: SnippetId, inputs: BaseInputs): MongoSnippet = MongoSnippet( simpleSnippetId = snippetId.url, user = snippetId.user.map(_.login), @@ -49,7 +56,7 @@ trait MongoDBSnippetsContainer extends SnippetsContainer with GenericMongoContai protected def insert(snippetId: SnippetId, inputs: BaseInputs): Future[Unit] = { val adjustedInputs = inputs match { case sbtInputs: SbtInputs => sbtInputs.withSavedConfig - case _ => inputs + case _ => inputs } val snippet = toBson(toMongoSnippet(snippetId, adjustedInputs)) snippets.insertOne(snippet).toFuture().map(_ => ()) @@ -63,37 +70,35 @@ trait MongoDBSnippetsContainer extends SnippetsContainer with GenericMongoContai case None => Future.successful(None) } - override protected def hideFromUserProfile(snippetId: SnippetId): Future[Unit] = - updateSnippet(snippetId)(oldSnippet => - oldSnippet.copy(inputs = oldSnippet.inputs.copyBaseInput(isShowingInUserProfile = false)) - ).map(_ => ()) + override protected def hideFromUserProfile(snippetId: SnippetId): Future[Unit] = updateSnippet(snippetId)( + oldSnippet => oldSnippet.copy(inputs = oldSnippet.inputs.copyBaseInput(isShowingInUserProfile = false)) + ).map(_ => ()) private def select(snippetId: SnippetId) = equal("simpleSnippetId", snippetId.url) def delete(snippetId: SnippetId): Future[Boolean] = snippets.deleteOne(select(snippetId)).map(_.wasAcknowledged).headOption().map(_.getOrElse(false)) - def appendOutput(progress: SnippetProgress): Future[Unit] = - progress.snippetId match { - case Some(snippetId) => - val selection = select(snippetId) - - val appendOutputLogs = { - val update = push("progresses", toBson(progress)) - snippets.updateOne(selection, update).map(_.wasAcknowledged).headOption() - } - - val setScalaJsOutput = (progress.scalaJsContent, progress.scalaJsSourceMapContent) match { - case (Some(scalaJsContent), Some(scalaJsSourceMapContent)) => - val updateJs = - combine(set("scalaJsContent", scalaJsContent), set("scalaJsSourceMapContent", scalaJsSourceMapContent)) - snippets.updateOne(selection, updateJs.toBsonDocument).map(_.wasAcknowledged).headOption() - case _ => Future(()) - } - - appendOutputLogs.zip(setScalaJsOutput).map(_ => ()) - case None => Future(()) - } + def appendOutput(progress: SnippetProgress): Future[Unit] = progress.snippetId match { + case Some(snippetId) => + val selection = select(snippetId) + + val appendOutputLogs = { + val update = push("progresses", toBson(progress)) + snippets.updateOne(selection, update).map(_.wasAcknowledged).headOption() + } + + val setScalaJsOutput = (progress.scalaJsContent, progress.scalaJsSourceMapContent) match { + case (Some(scalaJsContent), Some(scalaJsSourceMapContent)) => + val updateJs = + combine(set("scalaJsContent", scalaJsContent), set("scalaJsSourceMapContent", scalaJsSourceMapContent)) + snippets.updateOne(selection, updateJs.toBsonDocument).map(_.wasAcknowledged).headOption() + case _ => Future(()) + } + + appendOutputLogs.zip(setScalaJsOutput).map(_ => ()) + case None => Future(()) + } def readMongoSnippet(snippetId: SnippetId): Future[Option[MongoSnippet]] = { snippets @@ -122,11 +127,15 @@ trait MongoDBSnippetsContainer extends SnippetsContainer with GenericMongoContai fields( include("snippetId"), computed("inputs.code", Document("$ifNull" -> Seq("$inputs.SbtInputs.code", "$inputs.ScalaCliInputs.code"))), - computed("inputs.target", Document("$ifNull" -> - BsonArray("$inputs.SbtInputs.target", Document("ScalaCli" -> "$inputs.ScalaCliInputs.target"))) + computed( + "inputs.target", + Document( + "$ifNull" -> + BsonArray("$inputs.SbtInputs.target", Document("ScalaCli" -> "$inputs.ScalaCliInputs.target")) + ) ), include("time") - ), + ) ) .map(fromBson[ShortMongoSnippet]) diff --git a/storage/src/main/scala/org/scastie/storage/mongodb/MongoDBStoredClasses.scala b/storage/src/main/scala/org/scastie/storage/mongodb/MongoDBStoredClasses.scala index fb78beaee..7d77f5d72 100644 --- a/storage/src/main/scala/org/scastie/storage/mongodb/MongoDBStoredClasses.scala +++ b/storage/src/main/scala/org/scastie/storage/mongodb/MongoDBStoredClasses.scala @@ -1,9 +1,8 @@ package org.scastie.storage -import org.scastie.api._ - import io.circe._ import io.circe.generic.semiauto._ +import org.scastie.api._ sealed trait BaseMongoSnippet { def snippetId: SnippetId diff --git a/storage/src/main/scala/org/scastie/storage/mongodb/MongoDBUsersContainer.scala b/storage/src/main/scala/org/scastie/storage/mongodb/MongoDBUsersContainer.scala index 7c385f39f..9aeeedd78 100644 --- a/storage/src/main/scala/org/scastie/storage/mongodb/MongoDBUsersContainer.scala +++ b/storage/src/main/scala/org/scastie/storage/mongodb/MongoDBUsersContainer.scala @@ -1,15 +1,16 @@ package org.scastie.storage.mongodb -import org.scastie.storage._ -import org.mongodb.scala._ -import org.mongodb.scala.model.Updates._ -import org.mongodb.scala.model._ - +import scala.concurrent.duration._ import scala.concurrent.Await import scala.concurrent.Future -import scala.concurrent.duration._ + +import org.mongodb.scala._ +import org.mongodb.scala.model._ +import org.mongodb.scala.model.Updates._ +import org.scastie.storage._ trait MongoDBUsersContainer extends UsersContainer with GenericMongoContainer { + lazy val users = { val db = database.getCollection[Document]("users") Await.result(db.createIndex(Indexes.ascending("user"), IndexOptions().unique(true)).head(), Duration.Inf) diff --git a/storage/src/test/scala/org/scastie/storage/ContainerTest.scala b/storage/src/test/scala/org/scastie/storage/ContainerTest.scala index 9dcaef974..8314fe5e9 100644 --- a/storage/src/test/scala/org/scastie/storage/ContainerTest.scala +++ b/storage/src/test/scala/org/scastie/storage/ContainerTest.scala @@ -1,26 +1,26 @@ package org.scastie.storage -import org.scastie.api._ -import org.scastie.storage.filesystem.FilesystemContainer -import org.scastie.storage.mongodb.MongoDBContainer -import org.scalatest.BeforeAndAfterAll -import org.scalatest.OptionValues -import org.scalatest.funsuite.AnyFunSuite - import java.io.IOException +import java.nio.file.attribute.BasicFileAttributes import java.nio.file.FileVisitResult import java.nio.file.Files import java.nio.file.Path import java.nio.file.SimpleFileVisitor -import java.nio.file.attribute.BasicFileAttributes import java.util.concurrent.Executors +import scala.concurrent.duration._ import scala.concurrent.Await import scala.concurrent.ExecutionContext import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future -import scala.concurrent.duration._ import scala.util.Random +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.BeforeAndAfterAll +import org.scalatest.OptionValues +import org.scastie.api._ +import org.scastie.storage.filesystem.FilesystemContainer +import org.scastie.storage.mongodb.MongoDBContainer + class ContainerTest extends AnyFunSuite with BeforeAndAfterAll with OptionValues { val mongo = sys.props.get("SnippetsContainerTest.mongo").flatMap(_.toBooleanOption).contains(true) println(s"ContainerTest using mongodb: $mongo") @@ -28,11 +28,10 @@ class ContainerTest extends AnyFunSuite with BeforeAndAfterAll with OptionValues val oldRoot = Files.createTempDirectory("old-test") private val testContainer: SnippetsContainer with UsersContainer = { - if (mongo) - new MongoDBContainer(defaultConfig = true) + if (mongo) new MongoDBContainer(defaultConfig = true) else { new FilesystemContainer(root, oldRoot)( - ExecutionContext.fromExecutorService(Executors.newSingleThreadExecutor()) + ExecutionContext.fromExecutorService(Executors.newSingleThreadExecutor()) ) } } @@ -62,13 +61,13 @@ class ContainerTest extends AnyFunSuite with BeforeAndAfterAll with OptionValues } ) } + Seq(SbtInputs.default.withSavedConfig, ScalaCliInputs.default).map { inputType => val typeName = inputType.getClass.getSimpleName.stripSuffix("$") test(s"[$typeName] create snippet with logged in user") { val bob = "bob" - val snippetId = - testContainer.create(inputType, user = Some(UserLogin(bob))) + val snippetId = testContainer.create(inputType, user = Some(UserLogin(bob))) assert(snippetId.await.user.get.login == bob) } @@ -87,14 +86,11 @@ class ContainerTest extends AnyFunSuite with BeforeAndAfterAll with OptionValues } test(s"[$typeName] fork") { - val inputs = - inputType.copyBaseInput(code = "source", isShowingInUserProfile = true) + val inputs = inputType.copyBaseInput(code = "source", isShowingInUserProfile = true) val snippetId = testContainer.save(inputs, user = None).await - val forkedInputs = - inputType.copyBaseInput(code = "forked", isShowingInUserProfile = true) - val forkedSnippetId = - testContainer.fork(snippetId, forkedInputs, user = None).await + val forkedInputs = inputType.copyBaseInput(code = "forked", isShowingInUserProfile = true) + val forkedSnippetId = testContainer.fork(snippetId, forkedInputs, user = None).await val forkedBis = testContainer.readSnippet(forkedSnippetId).await.get @@ -104,13 +100,11 @@ class ContainerTest extends AnyFunSuite with BeforeAndAfterAll with OptionValues test(s"[$typeName] update") { val user = UserLogin("github-user-update" + Random.nextInt()) - val inputs1 = - inputType.copyBaseInput(code = "inputs1", isShowingInUserProfile = true) + val inputs1 = inputType.copyBaseInput(code = "inputs1", isShowingInUserProfile = true) val snippetId1 = testContainer.save(inputs1, Some(user)).await assert(snippetId1.user.get.update == 0) - val inputs2 = - inputType.copyBaseInput(code = "inputs2", isShowingInUserProfile = true) + val inputs2 = inputType.copyBaseInput(code = "inputs2", isShowingInUserProfile = true) val snippetId2 = testContainer.update(snippetId1, inputs2).await.get assert(snippetId2.user.get.update == 1, "we get a new update id") @@ -140,8 +134,7 @@ class ContainerTest extends AnyFunSuite with BeforeAndAfterAll with OptionValues val user2inputs = inputType.copyBaseInput(code = "inputs3") testContainer.save(user2inputs, Some(user2)).await - val inputs4 = - inputType.copyBaseInput(code = "inputs4", isShowingInUserProfile = false) + val inputs4 = inputType.copyBaseInput(code = "inputs4", isShowingInUserProfile = false) testContainer.create(inputs4, Some(user)).await val snippets = testContainer.listSnippets(user).await @@ -217,27 +210,36 @@ class ContainerTest extends AnyFunSuite with BeforeAndAfterAll with OptionValues } test(s"[$typeName] add new user") { - ensureUserCleanup("bob", { username => - val snippetId = testContainer.addNewUser(UserLogin(username)).await - assert(snippetId) - }) + ensureUserCleanup( + "bob", + { username => + val snippetId = testContainer.addNewUser(UserLogin(username)).await + assert(snippetId) + } + ) } test(s"[$typeName] get user privacy policy acceptance") { - ensureUserCleanup("bob", { username => - val snippetId = testContainer.addNewUser(UserLogin(username)).await - val response = testContainer.getPrivacyPolicyResponse(UserLogin(username)).await - assert(testContainer.deleteUser(UserLogin(username)).await == true) - }) + ensureUserCleanup( + "bob", + { username => + val snippetId = testContainer.addNewUser(UserLogin(username)).await + val response = testContainer.getPrivacyPolicyResponse(UserLogin(username)).await + assert(testContainer.deleteUser(UserLogin(username)).await == true) + } + ) } test(s"[$typeName] set user privacy policy acceptance") { - ensureUserCleanup("bob", { username => - val snippetId = testContainer.addNewUser(UserLogin(username)).await - val updatePrivacyPolicy = testContainer.setPrivacyPolicyResponse(UserLogin(username), false).await - val response = testContainer.getPrivacyPolicyResponse(UserLogin(username)).await - assert(response == false) - }) + ensureUserCleanup( + "bob", + { username => + val snippetId = testContainer.addNewUser(UserLogin(username)).await + val updatePrivacyPolicy = testContainer.setPrivacyPolicyResponse(UserLogin(username), false).await + val response = testContainer.getPrivacyPolicyResponse(UserLogin(username)).await + assert(response == false) + } + ) } test(s"[$typeName] remove user from privacy policy list") { diff --git a/utils/src/main/scala/org/scastie/util/Base64UUID.scala b/utils/src/main/scala/org/scastie/util/Base64UUID.scala index 6192a0eb9..22f3218ed 100644 --- a/utils/src/main/scala/org/scastie/util/Base64UUID.scala +++ b/utils/src/main/scala/org/scastie/util/Base64UUID.scala @@ -4,11 +4,11 @@ import java.nio.ByteBuffer import java.util.{Base64, UUID} object Base64UUID { + // example output: GGdknrcEQVu3elXyboKcYQ def create: String = { def toBase64(uuid: UUID): String = { - val (high, low) = - (uuid.getMostSignificantBits, uuid.getLeastSignificantBits) + val (high, low) = (uuid.getMostSignificantBits, uuid.getLeastSignificantBits) val buffer = ByteBuffer.allocate(java.lang.Long.BYTES * 2) buffer.putLong(high) buffer.putLong(low) @@ -26,4 +26,5 @@ object Base64UUID { res } + } diff --git a/utils/src/main/scala/org/scastie/util/BlockingProcess.scala b/utils/src/main/scala/org/scastie/util/BlockingProcess.scala index f8e48425a..c64366c94 100644 --- a/utils/src/main/scala/org/scastie/util/BlockingProcess.scala +++ b/utils/src/main/scala/org/scastie/util/BlockingProcess.scala @@ -1,20 +1,20 @@ /** - * Copyright (C) 2009-2014 Typesafe Inc. - */ + * Copyright (C) 2009-2014 Typesafe Inc. + */ package akka.contrib.process -import akka.actor.{Actor, ActorLogging, ActorRef, NoSerializationVerificationNeeded, Props, SupervisorStrategy, Terminated} -import akka.stream.{ActorAttributes, IOResult} -import akka.stream.scaladsl.{Sink, Source, StreamConverters} -import akka.util.{ByteString, Helpers} import java.io.File import java.lang.{Process => JavaProcess, ProcessBuilder => JavaProcessBuilder} import java.util.concurrent.TimeUnit - import scala.collection.immutable -import scala.concurrent.{Future, blocking} +import scala.concurrent.{blocking, Future} import scala.concurrent.duration.Duration +import akka.actor.{Actor, ActorLogging, ActorRef, NoSerializationVerificationNeeded, Props, SupervisorStrategy, Terminated} +import akka.stream.{ActorAttributes, IOResult} +import akka.stream.scaladsl.{Sink, Source, StreamConverters} +import akka.util.{ByteString, Helpers} + object BlockingProcess { def getPid(process: JavaProcess): Long = { @@ -22,125 +22,135 @@ object BlockingProcess { } /** - * The configuration key to use in order to override the dispatcher used for blocking IO. - */ - final val BlockingIODispatcherId = - "akka.process.blocking-process.blocking-io-dispatcher-id" + * The configuration key to use in order to override the dispatcher used for blocking IO. + */ + final val BlockingIODispatcherId = "akka.process.blocking-process.blocking-io-dispatcher-id" /** - * Sent to the receiver on startup - specifies the streams used for managing input, output and error respectively. - * This message should only be received by the parent of the BlockingProcess and should not be passed across the - * JVM boundary (the publishers are not serializable). - * - * @param stdin a `akka.stream.scaladsl.Sink[ByteString, Future[IOResult]]` for the standard input stream of the process - * @param stdout a `akka.stream.scaladsl.Source[ByteString, Future[IOResult]]` for the standard output stream of the process - * @param stderr a `akka.stream.scaladsl.Source[ByteString, Future[IOResult]]` for the standard error stream of the process - */ - case class Started(pid: Option[Long], - stdin: Sink[ByteString, Future[IOResult]], - stdout: Source[ByteString, Future[IOResult]], - stderr: Source[ByteString, Future[IOResult]]) - extends NoSerializationVerificationNeeded + * Sent to the receiver on startup - specifies the streams used for managing input, output and error respectively. + * This message should only be received by the parent of the BlockingProcess and should not be passed across the JVM + * boundary (the publishers are not serializable). + * + * @param stdin + * a `akka.stream.scaladsl.Sink[ByteString, Future[IOResult]]` for the standard input stream of the process + * @param stdout + * a `akka.stream.scaladsl.Source[ByteString, Future[IOResult]]` for the standard output stream of the process + * @param stderr + * a `akka.stream.scaladsl.Source[ByteString, Future[IOResult]]` for the standard error stream of the process + */ + case class Started( + pid: Option[Long], + stdin: Sink[ByteString, Future[IOResult]], + stdout: Source[ByteString, Future[IOResult]], + stderr: Source[ByteString, Future[IOResult]] + ) extends NoSerializationVerificationNeeded /** - * Sent to the receiver after the process has exited. - * - * @param exitValue the exit value of the process - */ + * Sent to the receiver after the process has exited. + * + * @param exitValue + * the exit value of the process + */ case class Exited(exitValue: Int) /** - * Send a request to destroy the process. - * On POSIX, this sends a SIGTERM, but implementation is platform specific. - */ + * Send a request to destroy the process. On POSIX, this sends a SIGTERM, but implementation is platform specific. + */ case object Destroy /** - * Send a request to forcibly destroy the process. - * On POSIX, this sends a SIGKILL, but implementation is platform specific. - */ + * Send a request to forcibly destroy the process. On POSIX, this sends a SIGKILL, but implementation is platform + * specific. + */ case object DestroyForcibly /** - * Sent if stdin from the process is terminated - */ + * Sent if stdin from the process is terminated + */ case object StdinTerminated /** - * Sent if stdout from the process is terminated - */ + * Sent if stdout from the process is terminated + */ case object StdoutTerminated /** - * Sent if stderr from the process is terminated - */ + * Sent if stderr from the process is terminated + */ case object StderrTerminated /** - * Create Props for a [[BlockingProcess]] actor. - * - * @param command signifies the program to be executed and its optional arguments - * @param workingDir the working directory for the process; default is the current working directory - * @param environment the environment for the process; default is `Map.emtpy` - * @param stdioTimeout the amount of time to tolerate waiting for a process to communicate back to this actor - * @return Props for a [[BlockingProcess]] actor - */ - def props(command: immutable.Seq[String], - workingDir: File = new File(System.getProperty("user.dir")), - environment: Map[String, String] = Map.empty, - stdioTimeout: Duration = Duration.Undefined) = - Props(new BlockingProcess(command, workingDir, environment, stdioTimeout)) + * Create Props for a [[BlockingProcess]] actor. + * + * @param command + * signifies the program to be executed and its optional arguments + * @param workingDir + * the working directory for the process; default is the current working directory + * @param environment + * the environment for the process; default is `Map.emtpy` + * @param stdioTimeout + * the amount of time to tolerate waiting for a process to communicate back to this actor + * @return + * Props for a [[BlockingProcess]] actor + */ + def props( + command: immutable.Seq[String], + workingDir: File = new File(System.getProperty("user.dir")), + environment: Map[String, String] = Map.empty, + stdioTimeout: Duration = Duration.Undefined + ) = Props(new BlockingProcess(command, workingDir, environment, stdioTimeout)) private def prepareCommand(command: Seq[String]) = if (Helpers.isWindows) List("cmd", "/c") ++ (command map winQuote) else command /** - * This quoting functionality is as recommended per http://bugs.java.com/view_bug.do?bug_id=6511002 - * The JDK can't change due to its backward compatibility requirements, but we have no such constraint - * here. Args should be able to be expressed consistently by the user of our API no matter whether - * execution is on Windows or not. - * - * @param s command string to be quoted - * @return quoted string - */ + * This quoting functionality is as recommended per http://bugs.java.com/view_bug.do?bug_id=6511002 The JDK can't + * change due to its backward compatibility requirements, but we have no such constraint here. Args should be able to + * be expressed consistently by the user of our API no matter whether execution is on Windows or not. + * + * @param s + * command string to be quoted + * @return + * quoted string + */ private def winQuote(s: String): String = { - def needsQuoting(s: String) = - s.isEmpty || (s exists ( - c => c == ' ' || c == '\t' || c == '\\' || c == '"' - )) + def needsQuoting(s: String) = s.isEmpty || (s exists (c => c == ' ' || c == '\t' || c == '\\' || c == '"')) if (needsQuoting(s)) { val quoted = s .replaceAll("""([\\]*)"""", """$1$1\\"""") .replaceAll("""([\\]*)\z""", "$1$1") s""""$quoted"""" - } else - s + } else s } + } /** - * This actor uses the JDK process API. As such, more memory given that more threads are consumed. Favor the - * [[NonBlockingProcess]] actor unless you *need* to use the JDK. - * - * BlockingProcess encapsulates an operating system process and its ability to be communicated with via stdio i.e. - * stdin, stdout and stderr. The reactive streams for stdio are communicated in a BlockingProcess.Started event - * upon the actor being established. The parent actor is then subsequently streamed - * stdout and stderr events. When the process exists (determined by periodically polling process.isAlive()) then - * the process's exit code is communicated to the receiver in a BlockingProcess.Exited event. - * - * A dispatcher as indicated by the "akka.process.blocking-process.blocking-io-dispatcher-id" setting is used - * internally by the actor as various JDK calls are made which can block. - */ -class BlockingProcess(command: immutable.Seq[String], directory: File, environment: Map[String, String], stdioTimeout: Duration) - extends Actor - with ActorLogging { + * This actor uses the JDK process API. As such, more memory given that more threads are consumed. Favor the + * [[NonBlockingProcess]] actor unless you *need* to use the JDK. + * + * BlockingProcess encapsulates an operating system process and its ability to be communicated with via stdio i.e. + * stdin, stdout and stderr. The reactive streams for stdio are communicated in a BlockingProcess.Started event upon + * the actor being established. The parent actor is then subsequently streamed stdout and stderr events. When the + * process exists (determined by periodically polling process.isAlive()) then the process's exit code is communicated + * to the receiver in a BlockingProcess.Exited event. + * + * A dispatcher as indicated by the "akka.process.blocking-process.blocking-io-dispatcher-id" setting is used + * internally by the actor as various JDK calls are made which can block. + */ +class BlockingProcess( + command: immutable.Seq[String], + directory: File, + environment: Map[String, String], + stdioTimeout: Duration +) extends Actor + with ActorLogging { - import BlockingProcess._ import context.dispatcher + import BlockingProcess._ - override val supervisorStrategy: SupervisorStrategy = - SupervisorStrategy.stoppingStrategy + override val supervisorStrategy: SupervisorStrategy = SupervisorStrategy.stoppingStrategy override def preStart(): Unit = { println("preStart") @@ -153,12 +163,10 @@ class BlockingProcess(command: immutable.Seq[String], directory: File, environme pb.start() } - val blockingIODispatcherId = - context.system.settings.config.getString(BlockingIODispatcherId) + val blockingIODispatcherId = context.system.settings.config.getString(BlockingIODispatcherId) try { - val selfDispatcherAttribute = - ActorAttributes.dispatcher(blockingIODispatcherId) + val selfDispatcherAttribute = ActorAttributes.dispatcher(blockingIODispatcherId) val stdin = StreamConverters .fromOutputStream(() => process.getOutputStream(), autoFlush = true) @@ -200,8 +208,7 @@ class BlockingProcess(command: immutable.Seq[String], directory: File, environme case DestroyForcibly => log.debug("Received request to forcibly destroy the process.") tellDestroyer(ProcessDestroyer.DestroyForcibly) - case Terminated(_) => - context.stop(self) + case Terminated(_) => context.stop(self) case StdinTerminated => log.debug("Stdin was terminated") tellDestroyer(ProcessDestroyer.Inspect) @@ -213,34 +220,31 @@ class BlockingProcess(command: immutable.Seq[String], directory: File, environme tellDestroyer(ProcessDestroyer.Inspect) } - private def tellDestroyer(msg: Any) = - context.child("process-destroyer").foreach(_ ! msg) + private def tellDestroyer(msg: Any) = context.child("process-destroyer").foreach(_ ! msg) } private object ProcessDestroyer { /** - * The configuration key to use for the inspection interval. - */ - final val InspectionInterval = - "akka.process.blocking-process.inspection-interval" + * The configuration key to use for the inspection interval. + */ + final val InspectionInterval = "akka.process.blocking-process.inspection-interval" /** - * Inspect the Process to ensure it is still alive. This is necessary because - * a process can exit without its stdout/stderr file handles being closed, for - * instance if a process forks and a child continues to run when it dies, - * it will have a reference to those handles. - */ + * Inspect the Process to ensure it is still alive. This is necessary because a process can exit without its + * stdout/stderr file handles being closed, for instance if a process forks and a child continues to run when it + * dies, it will have a reference to those handles. + */ case object Inspect /** - * Request that process.destroy() be called - */ + * Request that process.destroy() be called + */ case object Destroy /** - * Request that process.destroyForcibly() be called - */ + * Request that process.destroyForcibly() be called + */ case object DestroyForcibly def props(process: JavaProcess, exitValueReceiver: ActorRef): Props = @@ -248,14 +252,13 @@ private object ProcessDestroyer { } private class ProcessDestroyer(process: JavaProcess, exitValueReceiver: ActorRef) extends Actor with ActorLogging { - import ProcessDestroyer._ import context.dispatcher + import ProcessDestroyer._ - private val inspectionInterval = - Duration( - context.system.settings.config.getDuration(InspectionInterval).toMillis, - TimeUnit.MILLISECONDS - ) + private val inspectionInterval = Duration( + context.system.settings.config.getDuration(InspectionInterval).toMillis, + TimeUnit.MILLISECONDS + ) private val inspectionTick = context.system.scheduler.scheduleAtFixedRate(inspectionInterval, inspectionInterval, self, Inspect) @@ -274,12 +277,9 @@ private class ProcessDestroyer(process: JavaProcess, exitValueReceiver: ActorRef } override def receive = { - case Destroy => - blocking(pkill()) - case DestroyForcibly => - blocking(pkill()) - case Inspect => - if (!process.isAlive) { + case Destroy => blocking(pkill()) + case DestroyForcibly => blocking(pkill()) + case Inspect => if (!process.isAlive) { log.debug("Process has terminated, stopping self") context.stop(self) } @@ -294,4 +294,5 @@ private class ProcessDestroyer(process: JavaProcess, exitValueReceiver: ActorRef } exitValueReceiver ! BlockingProcess.Exited(exitValue) } + } diff --git a/utils/src/main/scala/org/scastie/util/GraphStageForwarder.scala b/utils/src/main/scala/org/scastie/util/GraphStageForwarder.scala index 8b65d5a8b..f96fc9172 100644 --- a/utils/src/main/scala/org/scastie/util/GraphStageForwarder.scala +++ b/utils/src/main/scala/org/scastie/util/GraphStageForwarder.scala @@ -1,11 +1,11 @@ package org.scastie.util +import scala.reflect.runtime.universe._ + import akka.actor.ActorRef import akka.stream.{Attributes, Outlet, SourceShape} import akka.stream.stage.{GraphStage, GraphStageLogic} -import scala.reflect.runtime.universe._ - class GraphStageForwarder[T: TypeTag, U: TypeTag]( outletName: String, coordinator: ActorRef, diff --git a/utils/src/main/scala/org/scastie/util/GraphStageLogicForwarder.scala b/utils/src/main/scala/org/scastie/util/GraphStageLogicForwarder.scala index c657d8bec..1cbc0edee 100644 --- a/utils/src/main/scala/org/scastie/util/GraphStageLogicForwarder.scala +++ b/utils/src/main/scala/org/scastie/util/GraphStageLogicForwarder.scala @@ -1,14 +1,18 @@ package org.scastie.util +import scala.collection.mutable.{Queue => MQueue} +import scala.reflect.runtime.universe._ + import akka.actor.ActorRef import akka.stream.{Outlet, SourceShape} import akka.stream.stage.{GraphStageLogic, OutHandler} -import scala.collection.mutable.{Queue => MQueue} -import scala.reflect.runtime.universe._ - -class GraphStageLogicForwarder[T: TypeTag, U: TypeTag](out: Outlet[T], shape: SourceShape[T], coordinator: ActorRef, graphId: U) - extends GraphStageLogic(shape) { +class GraphStageLogicForwarder[T: TypeTag, U: TypeTag]( + out: Outlet[T], + shape: SourceShape[T], + coordinator: ActorRef, + graphId: U +) extends GraphStageLogic(shape) { setHandler( out, @@ -26,15 +30,12 @@ class GraphStageLogicForwarder[T: TypeTag, U: TypeTag](out: Outlet[T], shape: So private val buffer = MQueue.empty[T] - private def deliver(): Unit = - if (isAvailable(out) && buffer.nonEmpty) - push[T](out, buffer.dequeue) + private def deliver(): Unit = if (isAvailable(out) && buffer.nonEmpty) push[T](out, buffer.dequeue) - private def bufferElement(receive: (ActorRef, Any)): Unit = - receive match { - case (_, element: T @unchecked) => - buffer.enqueue(element) - deliver() - } + private def bufferElement(receive: (ActorRef, Any)): Unit = receive match { + case (_, element: T @unchecked) => + buffer.enqueue(element) + deliver() + } } diff --git a/utils/src/main/scala/org/scastie/util/ProcessActor.scala b/utils/src/main/scala/org/scastie/util/ProcessActor.scala index ec5600828..0b9e7cea1 100644 --- a/utils/src/main/scala/org/scastie/util/ProcessActor.scala +++ b/utils/src/main/scala/org/scastie/util/ProcessActor.scala @@ -3,28 +3,30 @@ package org.scastie.util import java.nio.file._ import java.time.Instant import java.util.concurrent.atomic.AtomicLong +import scala.concurrent.duration._ import akka.actor.{Actor, ActorRef, Props, Stash} import akka.contrib.process._ -import akka.stream.scaladsl.{Flow, Framing, Sink, Source} import akka.stream.{ActorMaterializer, ActorMaterializerSettings, OverflowStrategy, ThrottleMode} +import akka.stream.scaladsl.{Flow, Framing, Sink, Source} import akka.util.ByteString import org.scastie.api.{ProcessOutput, ProcessOutputType} import org.slf4j.LoggerFactory -import scala.concurrent.duration._ - object ProcessActor { case class Input(line: String) case object Shutdown - def props(command: List[String], - workingDir: Path = Paths.get(System.getProperty("user.dir")), - environment: Map[String, String] = Map.empty, - killOnExit: Boolean = false): Props = { + def props( + command: List[String], + workingDir: Path = Paths.get(System.getProperty("user.dir")), + environment: Map[String, String] = Map.empty, + killOnExit: Boolean = false + ): Props = { Props(new ProcessActor(command, workingDir, environment, killOnExit)) } + } /* @@ -34,8 +36,8 @@ object ProcessActor { */ class ProcessActor(command: List[String], workingDir: Path, environment: Map[String, String], killOnExit: Boolean) - extends Actor - with Stash { + extends Actor + with Stash { import ProcessActor._ @@ -49,12 +51,12 @@ class ProcessActor(command: List[String], workingDir: Path, environment: Map[Str // ) // import NonBlockingProcess._ - private val props = - BlockingProcess.props( - command = command, - workingDir = workingDir.toFile, - environment = environment - ) + private val props = BlockingProcess.props( + command = command, + workingDir = workingDir.toFile, + environment = environment + ) + import BlockingProcess._ private val process = context.actorOf(props, name = "process") @@ -76,6 +78,7 @@ class ProcessActor(command: List[String], workingDir: Path, environment: Map[Str ) private val outputId = new AtomicLong(0) + override def receive: Receive = { case Started(pid, stdin, stdout, stderr) => println("process started: " + pid) @@ -90,30 +93,26 @@ class ProcessActor(command: List[String], workingDir: Path, environment: Map[Str } ) .throttle(100, 1.second, 100, ThrottleMode.Shaping) - .runWith(Sink.fold(Instant.now) { - case (ts, output) => - val now = Instant.now - println(s"> ${output.id.getOrElse(0)} ${now.toEpochMilli - ts.toEpochMilli}ms: ${output.line}") - context.parent ! output - now + .runWith(Sink.fold(Instant.now) { case (ts, output) => + val now = Instant.now + println(s"> ${output.id.getOrElse(0)} ${now.toEpochMilli - ts.toEpochMilli}ms: ${output.line}") + context.parent ! output + now }) - val stdin2: Source[ByteString, ActorRef] = - Source - .actorRef[Input](Int.MaxValue, OverflowStrategy.fail) - .map { case Input(line) => ByteString(line + "\n") } + val stdin2: Source[ByteString, ActorRef] = Source + .actorRef[Input](Int.MaxValue, OverflowStrategy.fail) + .map { case Input(line) => ByteString(line + "\n") } - val ref: ActorRef = - Flow[ByteString] - .to(stdin) - .runWith(stdin2) + val ref: ActorRef = Flow[ByteString] + .to(stdin) + .runWith(stdin2) context.become(active(ref)) unstashAll() - case input: Input => - stash() + case input: Input => stash() } private def active(stdin: ActorRef): Receive = { @@ -121,9 +120,9 @@ class ProcessActor(command: List[String], workingDir: Path, environment: Map[Str println(s"< ${outputId.incrementAndGet()}: $input") stdin ! input - case Exited(exitValue) => - if (killOnExit) { + case Exited(exitValue) => if (killOnExit) { throw new Exception("process exited: " + exitValue) } } + } diff --git a/utils/src/main/scala/org/scastie/util/ReconnectingActor.scala b/utils/src/main/scala/org/scastie/util/ReconnectingActor.scala index 6904ea3f7..e38c46626 100644 --- a/utils/src/main/scala/org/scastie/util/ReconnectingActor.scala +++ b/utils/src/main/scala/org/scastie/util/ReconnectingActor.scala @@ -1,12 +1,12 @@ package org.scastie.util +import scala.concurrent.duration._ +import scala.concurrent.ExecutionContext.Implicits.global + import akka.actor.{Actor, ActorContext, ActorLogging, Cancellable} import akka.remote.DisassociatedEvent import org.scastie.api.ActorConnected -import scala.concurrent.ExecutionContext.Implicits.global -import scala.concurrent.duration._ - case class ReconnectInfo(serverHostname: String, serverAkkaPort: Int, actorHostname: String, actorAkkaPort: Int) trait ActorReconnecting extends Actor with ActorLogging { @@ -49,15 +49,13 @@ trait ActorReconnecting extends Actor with ActorLogging { case ev: DisassociatedEvent => { println("DisassociatedEvent " + ev) - val isServerHostname = - reconnectInfo - .map(info => ev.remoteAddress.host.contains(info.serverHostname)) - .getOrElse(false) + val isServerHostname = reconnectInfo + .map(info => ev.remoteAddress.host.contains(info.serverHostname)) + .getOrElse(false) - val isServerAkkaPort = - reconnectInfo - .map(info => ev.remoteAddress.port.contains(info.serverAkkaPort)) - .getOrElse(false) + val isServerAkkaPort = reconnectInfo + .map(info => ev.remoteAddress.port.contains(info.serverAkkaPort)) + .getOrElse(false) if (isServerHostname && isServerAkkaPort && ev.inbound) { log.warning("Disconnected from server") @@ -66,4 +64,5 @@ trait ActorReconnecting extends Actor with ActorLogging { } } } + } diff --git a/utils/src/main/scala/org/scastie/util/SbtTask.scala b/utils/src/main/scala/org/scastie/util/SbtTask.scala index 23d12484f..2bbbf52c5 100644 --- a/utils/src/main/scala/org/scastie/util/SbtTask.scala +++ b/utils/src/main/scala/org/scastie/util/SbtTask.scala @@ -1,8 +1,7 @@ package org.scastie.util -import org.scastie.api._ - import akka.actor.ActorRef +import org.scastie.api._ case class SbtTask(snippetId: SnippetId, inputs: SbtInputs, ip: String, login: Option[String], progressActor: ActorRef) diff --git a/utils/src/main/scala/org/scastie/util/ScalaCliTask.scala b/utils/src/main/scala/org/scastie/util/ScalaCliTask.scala index 36ca0b49c..d94879ee8 100644 --- a/utils/src/main/scala/org/scastie/util/ScalaCliTask.scala +++ b/utils/src/main/scala/org/scastie/util/ScalaCliTask.scala @@ -1,8 +1,7 @@ package org.scastie.util -import org.scastie.api._ - import akka.actor.ActorRef +import org.scastie.api._ case class ScalaCliActorTask(snippetId: SnippetId, inputs: ScalaCliInputs, ip: String, progressActor: ActorRef) case object StopRunner diff --git a/utils/src/main/scala/org/scastie/util/ScastieFileUtil.scala b/utils/src/main/scala/org/scastie/util/ScastieFileUtil.scala index 5e8aac023..d353ab6cc 100644 --- a/utils/src/main/scala/org/scastie/util/ScastieFileUtil.scala +++ b/utils/src/main/scala/org/scastie/util/ScastieFileUtil.scala @@ -1,10 +1,11 @@ package org.scastie.util -import java.nio.file._ import java.lang.management.ManagementFactory import java.nio.charset.StandardCharsets +import java.nio.file._ object ScastieFileUtil { + def slurp(src: Path): Option[String] = { if (Files.exists(src)) Some(Files.readAllLines(src).toArray.mkString("\n")) else None @@ -32,4 +33,5 @@ object ScastieFileUtil { } pid } + } diff --git a/utils/src/test/scala/org/scastie/util/ProcessActorTest.scala b/utils/src/test/scala/org/scastie/util/ProcessActorTest.scala index 58ad46e70..0f8361b0a 100644 --- a/utils/src/test/scala/org/scastie/util/ProcessActorTest.scala +++ b/utils/src/test/scala/org/scastie/util/ProcessActorTest.scala @@ -2,18 +2,21 @@ package org.scastie.util import java.io.File import java.nio.file.{Files, StandardCopyOption} +import scala.concurrent.duration._ import akka.actor.{Actor, ActorRef, ActorSystem} import akka.testkit.{ImplicitSender, TestActorRef, TestKit, TestProbe} +import org.scalatest.funsuite.AnyFunSuiteLike +import org.scalatest.BeforeAndAfterAll import org.scastie.api.ProcessOutput import org.scastie.api.ProcessOutputType._ import org.scastie.util.ProcessActor._ -import org.scalatest.BeforeAndAfterAll -import org.scalatest.funsuite.AnyFunSuiteLike -import scala.concurrent.duration._ - -class ProcessActorTest() extends TestKit(ActorSystem("ProcessActorTest")) with ImplicitSender with AnyFunSuiteLike with BeforeAndAfterAll { +class ProcessActorTest() + extends TestKit(ActorSystem("ProcessActorTest")) + with ImplicitSender + with AnyFunSuiteLike + with BeforeAndAfterAll { test("do it") { (1 to 10).foreach { i => @@ -34,7 +37,7 @@ class ProcessActorTest() extends TestKit(ActorSystem("ProcessActorTest")) with I def expected(msg0: String): Unit = { probe.expectMsgPF(8000.milliseconds) { case ProcessOutput(msg1, StdOut, _) if msg0.trim == msg1.trim => true - case ProcessOutput(msg1, StdOut, _) => + case ProcessOutput(msg1, StdOut, _) => println(s""""$msg1" != "$msg0"""") false } @@ -48,15 +51,16 @@ class ProcessActorTest() extends TestKit(ActorSystem("ProcessActorTest")) with I override def afterAll(): Unit = { TestKit.shutdownActorSystem(system) } + } class ProcessReceiver(command: String, probe: ActorRef) extends Actor { - private val props = - ProcessActor.props(command = List("bash", "-c", command.replace("\\", "/")), killOnExit = false) + private val props = ProcessActor.props(command = List("bash", "-c", command.replace("\\", "/")), killOnExit = false) private val process = context.actorOf(props, name = "process-receiver") override def receive: Receive = { case output: ProcessOutput => probe ! output case input: Input => process ! input } + } From 9924972b68f885fa7e6f462ef15d623dd8c3b1b4 Mon Sep 17 00:00:00 2001 From: warcholjakub Date: Wed, 1 Oct 2025 15:12:14 +0200 Subject: [PATCH 5/5] git-blame: ignore scalafmt formatting --- .git-blame-ignore-revs | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 000000000..d1db61005 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# scalafmt v3.9.10 +505443d572b777b1b40c87eb3e14958140769498 \ No newline at end of file