From 72a027fbb585d662db207b238ed33856e4590c75 Mon Sep 17 00:00:00 2001 From: Hubert Plociniczak Date: Thu, 14 Nov 2024 12:51:30 +0100 Subject: [PATCH 1/3] Optional `scripts` section in libraries' config This change adds a scripts/hooks section to config that allows to specify a list of available scripts for the given library. Scripts are defined in a free-form, meaning that any kind of value can be provided as a value and is parsed as a string. For example, ``` scripts: refresh: - Standard.Base.HTTP.Caches.refresh ``` --- .../src/main/java/org/enso/runner/Main.java | 4 +- .../src/main/scala/org/enso/pkg/Config.scala | 23 ++++-- .../src/main/scala/org/enso/pkg/Package.scala | 6 +- .../src/main/scala/org/enso/pkg/Script.scala | 80 +++++++++++++++++++ 4 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 lib/scala/pkg/src/main/scala/org/enso/pkg/Script.scala diff --git a/engine/runner/src/main/java/org/enso/runner/Main.java b/engine/runner/src/main/java/org/enso/runner/Main.java index fd99d8486c83..90b0d7266162 100644 --- a/engine/runner/src/main/java/org/enso/runner/Main.java +++ b/engine/runner/src/main/java/org/enso/runner/Main.java @@ -55,6 +55,7 @@ import org.slf4j.LoggerFactory; import org.slf4j.event.Level; import scala.Option$; +import scala.collection.immutable.List$; import scala.concurrent.ExecutionContext; import scala.concurrent.ExecutionContextExecutor; import scala.concurrent.duration.FiniteDuration; @@ -599,7 +600,8 @@ private void createNew( authors, nil(), "", - Option$.MODULE$.empty()); + Option$.MODULE$.empty(), + List$.MODULE$.empty()); throw exitSuccess(); } diff --git a/lib/scala/pkg/src/main/scala/org/enso/pkg/Config.scala b/lib/scala/pkg/src/main/scala/org/enso/pkg/Config.scala index 46435a9b6544..08f787db20b4 100644 --- a/lib/scala/pkg/src/main/scala/org/enso/pkg/Config.scala +++ b/lib/scala/pkg/src/main/scala/org/enso/pkg/Config.scala @@ -95,9 +95,7 @@ object Contact { * edition * @param componentGroups the description of component groups provided by this * package - * @param originalJson a Json object holding the original values that this - * Config was created from, used to preserve configuration - * keys that are not known + * @param scripts list of scripts that could be called for the given library */ case class Config( name: String, @@ -109,7 +107,8 @@ case class Config( maintainers: List[Contact], edition: Option[Editions.RawEdition], preferLocalLibraries: Boolean, - componentGroups: Option[ComponentGroups] + componentGroups: Option[ComponentGroups], + scripts: List[Script] ) { /** Converts the configuration into a YAML representation. */ @@ -147,6 +146,7 @@ object Config { val Edition: String = "edition" val PreferLocalLibraries = "prefer-local-libraries" val ComponentGroups = "component-groups" + val Scripts: String = "scripts" } implicit val yamlDecoder: YamlDecoder[Config] = @@ -164,6 +164,7 @@ object Config { val booleanDecoder = implicitly[YamlDecoder[Boolean]] val componentGroups = implicitly[YamlDecoder[Option[ComponentGroups]]] + val scriptsDecoder = implicitly[YamlDecoder[List[Script]]] for { name <- clazzMap .get(JsonFields.Name) @@ -220,6 +221,10 @@ object Config { .get(JsonFields.ComponentGroups) .map(componentGroups.decode) .getOrElse(Right(None)) + scripts <- clazzMap + .get(JsonFields.Scripts) + .map(scriptsDecoder.decode) + .getOrElse(Right(Nil)) } yield Config( name, normalizedName, @@ -230,7 +235,8 @@ object Config { maintainers, edition, preferLocalLibraries, - componentGroups + componentGroups, + scripts ) } } @@ -243,6 +249,7 @@ object Config { val booleanEncoder = implicitly[YamlEncoder[Boolean]] val componentGroupsEncoder = implicitly[YamlEncoder[ComponentGroups]] + val scriptsEncoder = implicitly[YamlEncoder[List[Script]]] val elements = new util.ArrayList[(String, Object)]() elements.add((JsonFields.Name, value.name)) @@ -288,6 +295,12 @@ object Config { ) ) + if (value.scripts.nonEmpty) { + elements.add( + (JsonFields.Scripts, scriptsEncoder.encode(value.scripts)) + ) + } + toMap(elements) } } diff --git a/lib/scala/pkg/src/main/scala/org/enso/pkg/Package.scala b/lib/scala/pkg/src/main/scala/org/enso/pkg/Package.scala index 32279c2764b0..f831820e756e 100644 --- a/lib/scala/pkg/src/main/scala/org/enso/pkg/Package.scala +++ b/lib/scala/pkg/src/main/scala/org/enso/pkg/Package.scala @@ -288,7 +288,8 @@ class PackageManager[F](implicit val fileSystem: FileSystem[F]) { authors: List[Contact] = List(), maintainers: List[Contact] = List(), license: String = "", - componentGroups: Option[ComponentGroups] = None + componentGroups: Option[ComponentGroups] = None, + scripts: List[Script] = Nil ): Package[F] = { val config = Config( name = name, @@ -300,7 +301,8 @@ class PackageManager[F](implicit val fileSystem: FileSystem[F]) { edition = edition, preferLocalLibraries = true, maintainers = maintainers, - componentGroups = componentGroups + componentGroups = componentGroups, + scripts = scripts ) create(root, config, template) } diff --git a/lib/scala/pkg/src/main/scala/org/enso/pkg/Script.scala b/lib/scala/pkg/src/main/scala/org/enso/pkg/Script.scala new file mode 100644 index 000000000000..1b745367b03c --- /dev/null +++ b/lib/scala/pkg/src/main/scala/org/enso/pkg/Script.scala @@ -0,0 +1,80 @@ +package org.enso.pkg + +import io.circe.{Decoder, DecodingFailure, Encoder, Json} +import org.enso.scala.yaml.{YamlDecoder, YamlEncoder} +import org.yaml.snakeyaml.error.YAMLException +import org.yaml.snakeyaml.nodes.{MappingNode, Node, ScalarNode, SequenceNode} + +import java.util + +case class Script(name: String, arguments: Seq[String]) + +object Script { + + val knownScripts = List("refresh") + + /** [[Encoder]] instance for the [[Script]]. */ + implicit val encoder: Encoder[Script] = { script => + val vs = script.arguments.map(Json.fromString) + Json.obj(script.name -> Json.arr(vs: _*)) + } + + implicit val yamlEncoder: YamlEncoder[Script] = + new YamlEncoder[Script] { + override def encode(value: Script) = { + val fields = new util.ArrayList[String](value.arguments.length) + value.arguments.foreach(v => fields.add(v)) + toMap(value.name, fields) + } + } + + private def find( + name: String, + values: Seq[String] + ): Either[String, Script] = { + if (knownScripts.contains(name)) Right(Script(name, values)) + else Left("Uknown script: " + name) + } + + /** [[Decoder]] instance for the [[Script]]. */ + implicit val decoder: Decoder[Script] = { json => + for { + key <- json.key.toRight(DecodingFailure("no key", Nil)) + fields <- json.get[List[String]](key) + script <- find(key, fields).left.map(v => DecodingFailure(v, Nil)) + } yield script + } + + implicit val yamlDecoder: YamlDecoder[Script] = + new YamlDecoder[Script] { + override def decode(node: Node): Either[Throwable, Script] = + node match { + case mappingNode: MappingNode => + if (mappingNode.getValue.size() == 1) { + val groupNode = mappingNode.getValue.get(0) + (groupNode.getKeyNode, groupNode.getValueNode) match { + case (scalarNode: ScalarNode, seqNode: SequenceNode) => + val stringDecoder = implicitly[YamlDecoder[String]] + val valuesDecoder = implicitly[YamlDecoder[Seq[String]]] + + for { + k <- stringDecoder.decode(scalarNode) + vs <- valuesDecoder.decode(seqNode) + script <- find(k, vs).left.map(new YAMLException(_)) + } yield script + case _ => + Left( + new YAMLException( + "Failed to decode script. Expected a map field" + ) + ) + } + } else { + Left( + new YAMLException("Failed to decode script") + ) + } + } + } + +} From 287f45259bc0a57719a21eab1ac45f94dd1bd247 Mon Sep 17 00:00:00 2001 From: Hubert Plociniczak Date: Thu, 14 Nov 2024 18:00:50 +0100 Subject: [PATCH 2/3] Tests, docs and other nits --- docs/distribution/packaging.md | 18 ++++++ .../ComponentGroupsResolverSpec.scala | 6 +- .../ComponentGroupsValidatorSpec.scala | 3 +- .../src/main/scala/org/enso/pkg/Config.scala | 18 ++++++ .../src/main/scala/org/enso/pkg/Script.scala | 2 +- .../test/scala/org/enso/pkg/ConfigSpec.scala | 59 ++++++++++++++++++- 6 files changed, 101 insertions(+), 5 deletions(-) diff --git a/docs/distribution/packaging.md b/docs/distribution/packaging.md index 2ff6c9929b22..9d0550cc6249 100644 --- a/docs/distribution/packaging.md +++ b/docs/distribution/packaging.md @@ -213,6 +213,24 @@ If the flag is not specified, it defaults to `false`, delegating all library resolution to the edition configuration. However, newly created projects will have it set to `true`. +### scripts + +**Optional** _List of scripts_: The name(s) of scripts that are supported by the +given library and should be invoked when an action is requested. Scripts +themselves can take a list of arguments and are constrained by the format of the +config file. + +Currently supported scripts are: + +- **Refresh** A list of Fully Qualified Names of methods of the library to be + called when a project is being recomputed. For example + +```yaml +refresh: + - Standard.Base.Http.Caches.refresh + - Standard.Base.Caches.refresh +``` + ### The `visualization` Directory As Enso is a visual language, a package may contain a specification of how data diff --git a/engine/language-server/src/test/scala/org/enso/languageserver/libraries/ComponentGroupsResolverSpec.scala b/engine/language-server/src/test/scala/org/enso/languageserver/libraries/ComponentGroupsResolverSpec.scala index 8466dd4d6184..c0d6236c15ed 100644 --- a/engine/language-server/src/test/scala/org/enso/languageserver/libraries/ComponentGroupsResolverSpec.scala +++ b/engine/language-server/src/test/scala/org/enso/languageserver/libraries/ComponentGroupsResolverSpec.scala @@ -280,7 +280,8 @@ object ComponentGroupsResolverSpec { maintainers = Nil, edition = None, preferLocalLibraries = true, - componentGroups = Some(componentGroups) + componentGroups = Some(componentGroups), + scripts = Nil ) /** Create a new config. */ @@ -298,7 +299,8 @@ object ComponentGroupsResolverSpec { maintainers = Nil, edition = None, preferLocalLibraries = true, - componentGroups = None + componentGroups = None, + scripts = Nil ) /** Create a new component group. */ diff --git a/engine/language-server/src/test/scala/org/enso/languageserver/libraries/ComponentGroupsValidatorSpec.scala b/engine/language-server/src/test/scala/org/enso/languageserver/libraries/ComponentGroupsValidatorSpec.scala index 1c3fc19e06e0..076e90e7482c 100644 --- a/engine/language-server/src/test/scala/org/enso/languageserver/libraries/ComponentGroupsValidatorSpec.scala +++ b/engine/language-server/src/test/scala/org/enso/languageserver/libraries/ComponentGroupsValidatorSpec.scala @@ -207,7 +207,8 @@ object ComponentGroupsValidatorSpec { ), extendedGroups = Nil ) - ) + ), + scripts = Nil ) /** Create a library name from config. */ diff --git a/lib/scala/pkg/src/main/scala/org/enso/pkg/Config.scala b/lib/scala/pkg/src/main/scala/org/enso/pkg/Config.scala index 08f787db20b4..af8735a46375 100644 --- a/lib/scala/pkg/src/main/scala/org/enso/pkg/Config.scala +++ b/lib/scala/pkg/src/main/scala/org/enso/pkg/Config.scala @@ -225,6 +225,7 @@ object Config { .get(JsonFields.Scripts) .map(scriptsDecoder.decode) .getOrElse(Right(Nil)) + .flatMap(validateScripts) } yield Config( name, normalizedName, @@ -239,6 +240,23 @@ object Config { scripts ) } + + private def validateScripts( + scripts: List[Script] + ): Either[Throwable, List[Script]] = { + val grouped = scripts.groupBy(_.name) + val nonUnique = grouped.find(_._2.length > 1) + nonUnique match { + case Some((scriptName, groupedScripts)) => + Left( + new YAMLException( + s"Scripts have to be unique, got ${groupedScripts.length} for $scriptName" + ) + ) + case _ => + Right(scripts) + } + } } implicit val encoderSnake: YamlEncoder[Config] = diff --git a/lib/scala/pkg/src/main/scala/org/enso/pkg/Script.scala b/lib/scala/pkg/src/main/scala/org/enso/pkg/Script.scala index 1b745367b03c..e22dfa0fc6d0 100644 --- a/lib/scala/pkg/src/main/scala/org/enso/pkg/Script.scala +++ b/lib/scala/pkg/src/main/scala/org/enso/pkg/Script.scala @@ -33,7 +33,7 @@ object Script { values: Seq[String] ): Either[String, Script] = { if (knownScripts.contains(name)) Right(Script(name, values)) - else Left("Uknown script: " + name) + else Left("Unknown script: " + name) } /** [[Decoder]] instance for the [[Script]]. */ diff --git a/lib/scala/pkg/src/test/scala/org/enso/pkg/ConfigSpec.scala b/lib/scala/pkg/src/test/scala/org/enso/pkg/ConfigSpec.scala index d813d7fc0912..35b63d9b311f 100644 --- a/lib/scala/pkg/src/test/scala/org/enso/pkg/ConfigSpec.scala +++ b/lib/scala/pkg/src/test/scala/org/enso/pkg/ConfigSpec.scala @@ -32,7 +32,9 @@ class ConfigSpec Contact(None, Some("c@example.com")) ), preferLocalLibraries = true, - componentGroups = None + componentGroups = None, + scripts = + List(Script("refresh", List("Standard.Base.Http.Caches.refresh"))) ) val deserialized = Config.fromYaml(config.toYaml).get deserialized shouldEqual config @@ -71,6 +73,61 @@ class ConfigSpec } } + "Scripts" should { + "correctly de-serialize and serialize back" in { + val config = + """|name: FooBar + |namespace: local + |scripts: + |- refresh: + | - Standard.Base.Http.Caches.refresh + | - Standard.Base.Caches.refresh + |""".stripMargin + val parsed = Config.fromYaml(config).get + + val expectedScripts = List( + Script( + "refresh", + List( + "Standard.Base.Http.Caches.refresh", + "Standard.Base.Caches.refresh" + ) + ) + ) + parsed.scripts shouldEqual expectedScripts + val serialized = parsed.toYaml + serialized shouldEqual config + } + + "reject duplicate entries" in { + val config = + """|name: FooBar + |namespace: local + |scripts: + |- refresh: + | - Standard.Base.Http.Caches.refresh + |- refresh: + | - Standard.Base.Http.Caches.refresh + |""".stripMargin + val parsed = Config.fromYaml(config) + parsed.isFailure shouldBe true + parsed.failed.get.getMessage should include("Scripts have to be unique") + } + + "reject unknown scripts" in { + val config = + """|name: FooBar + |namespace: local + |scripts: + |- startup: + | - Standard.Base.Boot.start + |""".stripMargin + val parsed = Config.fromYaml(config) + parsed.isFailure shouldBe true + parsed.failed.get.getMessage should include("Unknown script: startup") + } + } + "Component groups" should { "correctly de-serialize and serialize back the components syntax" in { From ffd17c87a29c1f0f78a52aa52bc98f9894b5c0ad Mon Sep 17 00:00:00 2001 From: Hubert Plociniczak Date: Thu, 14 Nov 2024 18:10:41 +0100 Subject: [PATCH 3/3] Remove check --- .../src/main/scala/org/enso/pkg/Script.scala | 20 ++++--------------- .../test/scala/org/enso/pkg/ConfigSpec.scala | 6 +++--- 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/lib/scala/pkg/src/main/scala/org/enso/pkg/Script.scala b/lib/scala/pkg/src/main/scala/org/enso/pkg/Script.scala index e22dfa0fc6d0..eec574f0d4b9 100644 --- a/lib/scala/pkg/src/main/scala/org/enso/pkg/Script.scala +++ b/lib/scala/pkg/src/main/scala/org/enso/pkg/Script.scala @@ -11,8 +11,6 @@ case class Script(name: String, arguments: Seq[String]) object Script { - val knownScripts = List("refresh") - /** [[Encoder]] instance for the [[Script]]. */ implicit val encoder: Encoder[Script] = { script => val vs = script.arguments.map(Json.fromString) @@ -28,21 +26,12 @@ object Script { } } - private def find( - name: String, - values: Seq[String] - ): Either[String, Script] = { - if (knownScripts.contains(name)) Right(Script(name, values)) - else Left("Unknown script: " + name) - } - /** [[Decoder]] instance for the [[Script]]. */ implicit val decoder: Decoder[Script] = { json => for { key <- json.key.toRight(DecodingFailure("no key", Nil)) fields <- json.get[List[String]](key) - script <- find(key, fields).left.map(v => DecodingFailure(v, Nil)) - } yield script + } yield Script(key, fields) } implicit val yamlDecoder: YamlDecoder[Script] = @@ -58,10 +47,9 @@ object Script { val valuesDecoder = implicitly[YamlDecoder[Seq[String]]] for { - k <- stringDecoder.decode(scalarNode) - vs <- valuesDecoder.decode(seqNode) - script <- find(k, vs).left.map(new YAMLException(_)) - } yield script + k <- stringDecoder.decode(scalarNode) + vs <- valuesDecoder.decode(seqNode) + } yield Script(k, vs) case _ => Left( new YAMLException( diff --git a/lib/scala/pkg/src/test/scala/org/enso/pkg/ConfigSpec.scala b/lib/scala/pkg/src/test/scala/org/enso/pkg/ConfigSpec.scala index 35b63d9b311f..fbf3e2d7131c 100644 --- a/lib/scala/pkg/src/test/scala/org/enso/pkg/ConfigSpec.scala +++ b/lib/scala/pkg/src/test/scala/org/enso/pkg/ConfigSpec.scala @@ -114,7 +114,7 @@ class ConfigSpec parsed.failed.get.getMessage should include("Scripts have to be unique") } - "reject unknown scripts" in { + "accept unknown scripts" in { val config = """|name: FooBar |namespace: local @@ -123,8 +123,8 @@ class ConfigSpec | - Standard.Base.Boot.start |""".stripMargin val parsed = Config.fromYaml(config) - parsed.isFailure shouldBe true - parsed.failed.get.getMessage should include("Unknown script: startup") + parsed.isSuccess shouldBe true + parsed.get.scripts.head.name shouldBe "startup" } }