From 76f26731f5c4c730342e5badd2204707bc693377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Fri, 29 Sep 2023 03:17:51 +0200 Subject: [PATCH 1/4] WIP: @examples trait for completions --- modules/core/src/test/smithy/demo.smithy | 22 +++++++ .../language/CompletionProvider.scala | 9 ++- .../language/CompletionVisitor.scala | 57 +++++++++++++++++-- .../playground/plugins/PlaygroundPlugin.scala | 2 +- 4 files changed, 82 insertions(+), 8 deletions(-) diff --git a/modules/core/src/test/smithy/demo.smithy b/modules/core/src/test/smithy/demo.smithy index b0799872..148797b2 100644 --- a/modules/core/src/test/smithy/demo.smithy +++ b/modules/core/src/test/smithy/demo.smithy @@ -49,6 +49,28 @@ operation GetVersion { @documentation(""" Create a hero. """) +@examples([{ + title: "Valid input" + documentation: "This is a valid input" + input: { + hero: { + good: { + howGood: 10 + } + } + } +}, { + title: "Valid input v2" + documentation: "This is also a valid input, but for a bad hero" + input: { + hero: { + bad: { + evilName: "Evil" + powerLevel: 10 + } + } + } +}]) operation CreateHero { input: CreateHeroInput output: CreateHeroOutput diff --git a/modules/language-support/src/main/scala/playground/language/CompletionProvider.scala b/modules/language-support/src/main/scala/playground/language/CompletionProvider.scala index 56e1890c..a8b7a09f 100644 --- a/modules/language-support/src/main/scala/playground/language/CompletionProvider.scala +++ b/modules/language-support/src/main/scala/playground/language/CompletionProvider.scala @@ -16,6 +16,8 @@ import playground.smithyql.SourceFile import playground.smithyql.WithSource import playground.smithyql.parser.SourceParser import playground.smithyql.syntax._ +import smithy.api.Examples +import smithy4s.Hints import smithy4s.dynamic.DynamicSchemaIndex trait CompletionProvider { @@ -103,7 +105,12 @@ object CompletionProvider { .service .endpoints .map { endpoint => - OperationName[Id](endpoint.name) -> endpoint.input.compile(CompletionVisitor) + OperationName[Id](endpoint.name) -> endpoint + .input + .addHints( + endpoint.hints.get(Examples).map(Hints(_)).getOrElse(Hints.empty) + ) + .compile(CompletionVisitor) } .toMap } diff --git a/modules/language-support/src/main/scala/playground/language/CompletionVisitor.scala b/modules/language-support/src/main/scala/playground/language/CompletionVisitor.scala index 4cc15da1..14e70d39 100644 --- a/modules/language-support/src/main/scala/playground/language/CompletionVisitor.scala +++ b/modules/language-support/src/main/scala/playground/language/CompletionVisitor.scala @@ -3,10 +3,12 @@ package playground.language import cats.Id import cats.implicits._ import cats.kernel.Eq +import playground.NodeEncoder import playground.ServiceNameExtractor import playground.TextUtils import playground.language.CompletionItem.InsertUseClause.NotRequired import playground.language.CompletionItem.InsertUseClause.Required +import playground.smithyql.InputNode import playground.smithyql.NodeContext import playground.smithyql.NodeContext.EmptyPath import playground.smithyql.NodeContext.PathEntry @@ -23,6 +25,7 @@ import playground.smithyql.WithSource import playground.smithyql.format.Formatter import smithy.api import smithy4s.Bijection +import smithy4s.Document import smithy4s.Endpoint import smithy4s.Hints import smithy4s.Lazy @@ -145,15 +148,17 @@ object CompletionItem { label: String, insertText: InsertText, schema: Schema[_], + sortTextOverride: Option[String] = None, ): CompletionItem = { val isField = kind === CompletionItemKind.Field - val sortText = + val sortText = sortTextOverride.orElse { isField match { case true if isRequiredField(schema) => Some(s"1_$label") case true => Some(s"2_$label") case false => None } + } CompletionItem( kind = kind, @@ -530,15 +535,55 @@ object CompletionVisitor extends SchemaVisitor[CompletionResolver] { fields: Vector[SchemaField[S, _]], make: IndexedSeq[Any] => S, ): CompletionResolver[S] = { + // Artificial schema resembling this one. Should be pretty much equivalent. + val schema = Schema.struct(fields)(make).addHints(hints).withId(shapeId) + val documentDecoder = Document.Decoder.fromSchema(schema) + + val nodeEncoder = NodeEncoder.derive(schema) + val compiledFields = fields.map(field => (field.mapK(this), field.instance)) + /* todo: pass this outside of Hints? (visitor context?) */ + val examples = hints + .get(api.Examples) + .foldMap(_.value) + .zipWithIndex + .map { case (example, index) => + val name = example.title + val doc = example.documentation + + val text = Formatter[InputNode] + .format( + nodeEncoder + .toNode(documentDecoder.decode(example.input.get).toTry.get /* todo: be graceful */ ) + .mapK(WithSource.liftId), + Int.MaxValue, + ) + .trim() + .tail + .init /* HACK: trim opening/closing braces */ + .trim() + + CompletionItem.fromHints( + kind = CompletionItemKind.Constant /* todo */, + label = s"Example: $name", + insertText = InsertText.JustString(text), + // issue: this doesn't work if the schema already has a Documentation hint. We should remove it first, or do something else. + schema = schema.addHints( + doc.map(api.Documentation(_)).map(Hints(_)).getOrElse(Hints.empty) + ), + sortTextOverride = Some(s"0_$index"), + ) + } + structLike( inBody = - fields - // todo: filter out present fields - .sortBy(field => (field.isRequired, field.label)) - .map(CompletionItem.fromField) - .toList, + examples ++ + fields + // todo: filter out present fields + .sortBy(field => (field.isRequired, field.label)) + .map(CompletionItem.fromField) + .toList, inValue = ( h, diff --git a/modules/plugin-core/src/main/scala/playground/plugins/PlaygroundPlugin.scala b/modules/plugin-core/src/main/scala/playground/plugins/PlaygroundPlugin.scala index 03541224..9fa54bc2 100644 --- a/modules/plugin-core/src/main/scala/playground/plugins/PlaygroundPlugin.scala +++ b/modules/plugin-core/src/main/scala/playground/plugins/PlaygroundPlugin.scala @@ -51,7 +51,7 @@ object SimpleHttpBuilder { service: Service[Alg], backend: Client[F], ): Either[UnsupportedProtocolError, FunctorAlgebra[Alg, F]] = - builder(service).client(backend).use + builder(service).client(backend).make } From 658211073bc555ce3ce1be12fb8da2da10a9fd05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Fri, 12 Apr 2024 01:00:24 +0200 Subject: [PATCH 2/4] Less hacky brace stuff --- .../main/scala/playground/smithyql/AST.scala | 6 +++ .../smithyql/format/Formatter.scala | 31 ++++++++--- .../language/CompletionVisitor.scala | 51 +++++++++++-------- 3 files changed, 59 insertions(+), 29 deletions(-) diff --git a/modules/ast/src/main/scala/playground/smithyql/AST.scala b/modules/ast/src/main/scala/playground/smithyql/AST.scala index 2b44037c..1c605bb2 100644 --- a/modules/ast/src/main/scala/playground/smithyql/AST.scala +++ b/modules/ast/src/main/scala/playground/smithyql/AST.scala @@ -126,6 +126,12 @@ sealed trait InputNode[F[_]] extends AST[F] { fk: F ~> G ): InputNode[G] + def asStruct: Option[Struct[F]] = + this match { + case Struct(fields) => Some(Struct(fields)) + case _ => None + } + } final case class OperationName[F[_]]( diff --git a/modules/formatter/src/main/scala/playground/smithyql/format/Formatter.scala b/modules/formatter/src/main/scala/playground/smithyql/format/Formatter.scala index 0cf1ae1f..ad25cc25 100644 --- a/modules/formatter/src/main/scala/playground/smithyql/format/Formatter.scala +++ b/modules/formatter/src/main/scala/playground/smithyql/format/Formatter.scala @@ -28,6 +28,13 @@ object Formatter { implicit val useClauseFormatter: Formatter[UseClause] = writeDoc implicit val preludeFormatter: Formatter[Prelude] = writeDoc implicit val qonFormatter: Formatter[QueryOperationName] = writeDoc + + implicit val fieldsFormatter: Formatter[Struct.Fields] = + ( + f, + w, + ) => FormattingVisitor.writeStructFields(WithSource.liftId(f)).renderTrim(w) + implicit val inputNodeFormatter: Formatter[InputNode] = writeDoc implicit val structFormatter: Formatter[Struct] = writeDoc implicit val listedFormatter: Formatter[Listed] = writeDoc @@ -108,9 +115,14 @@ private[format] object FormattingVisitor extends ASTVisitor[WithSource, Doc] { v query: WithSource[Query[WithSource]] ): Doc = printGeneric(query) + // no braces + def writeStructFields( + fields: WithSource[Struct.Fields[WithSource]] + ): Doc = writeCommaSeparated(fields.map(_.value))(writeField) + override def struct( fields: WithSource[Struct.Fields[WithSource]] - ): Doc = writeBracketed(fields.map(_.value))(Doc.char('{'), Doc.char('}'))(writeField) + ): Doc = Doc.char('{') + writeStructFields(fields) + Doc.char('}') private def forceLineAfterTrailingComments[A]( printer: WithSource[A] => Doc @@ -177,6 +189,16 @@ private[format] object FormattingVisitor extends ASTVisitor[WithSource, Doc] { v // Force newlines between fields fields.map(renderField).intercalate(Doc.hardLine) + private def writeCommaSeparated[T]( + items: WithSource[List[T]] + )( + renderItem: T => Doc + ): Doc = + Doc.hardLine + + printWithComments(items)(writeFields(_)(renderItem(_) + Doc.comma)) + .indent(2) + + Doc.hardLine + private def writeBracketed[T]( items: WithSource[List[T]] )( @@ -184,12 +206,7 @@ private[format] object FormattingVisitor extends ASTVisitor[WithSource, Doc] { v after: Doc, )( renderItem: T => Doc - ): Doc = - before + Doc.hardLine + - printWithComments(items)(writeFields(_)(renderItem(_) + Doc.comma)) - .indent(2) + - Doc.hardLine + - after + ): Doc = before + writeCommaSeparated(items)(renderItem) + after def writeIdent( ident: QualifiedIdentifier diff --git a/modules/language-support/src/main/scala/playground/language/CompletionVisitor.scala b/modules/language-support/src/main/scala/playground/language/CompletionVisitor.scala index 6f467188..39c06ad0 100644 --- a/modules/language-support/src/main/scala/playground/language/CompletionVisitor.scala +++ b/modules/language-support/src/main/scala/playground/language/CompletionVisitor.scala @@ -8,7 +8,6 @@ import playground.ServiceNameExtractor import playground.TextUtils import playground.language.CompletionItem.InsertUseClause.NotRequired import playground.language.CompletionItem.InsertUseClause.Required -import playground.smithyql.InputNode import playground.smithyql.NodeContext import playground.smithyql.NodeContext.EmptyPath import playground.smithyql.NodeContext.PathEntry @@ -594,32 +593,40 @@ object CompletionVisitor extends SchemaVisitor[CompletionResolver] { .get(api.Examples) .foldMap(_.value) .zipWithIndex - .map { case (example, index) => + .flatMap { case (example, index) => val name = example.title val doc = example.documentation - val text = Formatter[InputNode] - .format( - nodeEncoder - .toNode(documentDecoder.decode(example.input.get).toTry.get /* todo: be graceful */ ) - .mapK(WithSource.liftId), - Int.MaxValue, + for { + input <- example.input + decoded <- documentDecoder.decode(input).toOption + // note: we could've transcoded from Document to Node directly, without the intermediate decoding + // but the examples we suggest should be valid, and this is the only way to ensure that. + encoded = nodeEncoder.toNode(decoded) + + // we're only covering inputs, and operation inputs must be structures. + asObject <- encoded.asStruct + } yield { + val text = Formatter[Struct.Fields] + .format( + asObject + .fields + .mapK(WithSource.liftId), + Int.MaxValue, + ) + + CompletionItem.fromHints( + kind = CompletionItemKind.Constant /* todo */, + label = s"Example: $name", + insertText = InsertText.JustString(text), + // issue: this doesn't work if the schema already has a Documentation hint. We should remove it first, or do something else. + schema = schema.addHints( + doc.map(api.Documentation(_)).map(Hints(_)).getOrElse(Hints.empty) + ), + sortTextOverride = Some(s"0_$index"), ) - .trim() - .tail - .init /* HACK: trim opening/closing braces */ - .trim() - CompletionItem.fromHints( - kind = CompletionItemKind.Constant /* todo */, - label = s"Example: $name", - insertText = InsertText.JustString(text), - // issue: this doesn't work if the schema already has a Documentation hint. We should remove it first, or do something else. - schema = schema.addHints( - doc.map(api.Documentation(_)).map(Hints(_)).getOrElse(Hints.empty) - ), - sortTextOverride = Some(s"0_$index"), - ) + } } structLike( From 8d61396ccd096e135cb2719b12419cf572f45fc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Fri, 12 Apr 2024 01:12:14 +0200 Subject: [PATCH 3/4] Move out of visitor --- .../language/CompletionVisitor.scala | 106 +++++++++++------- 1 file changed, 63 insertions(+), 43 deletions(-) diff --git a/modules/language-support/src/main/scala/playground/language/CompletionVisitor.scala b/modules/language-support/src/main/scala/playground/language/CompletionVisitor.scala index 39c06ad0..06d9d59d 100644 --- a/modules/language-support/src/main/scala/playground/language/CompletionVisitor.scala +++ b/modules/language-support/src/main/scala/playground/language/CompletionVisitor.scala @@ -393,6 +393,68 @@ object CompletionItem { ) } + // Examples for operation inputs. + // TODO: currently only works inside the struct (and assumes that by rendering only fields, no braces). + // If/when we ever have graceful parsing in completions, we should handle other contexts, such as being outside of the struct. + def forInputExamples[S]( + schema: Schema[S] + ): List[CompletionItem] = { + val documentDecoder = Document.Decoder.fromSchema(schema) + val nodeEncoder = NodeEncoder.derive(schema) + + case class InputData( + name: String, + documentation: Option[String], + inputObject: Struct[Id], + ) + + val samples: List[InputData] = schema + .hints + .get(api.Examples) + .foldMap(_.value) + .flatMap { example => + val name = example.title + val doc = example.documentation + + for { + input <- example.input + decoded <- documentDecoder.decode(input).toOption + // note: we could've transcoded from Document to Node directly, without the intermediate decoding + // but the examples we suggest should be valid, and this is the only way to ensure that. + encoded = nodeEncoder.toNode(decoded) + + // we're only covering inputs, and operation inputs must be structures. + asObject <- encoded.asStruct + } yield InputData( + name = name, + documentation = doc, + inputObject = asObject, + ) + } + + samples.zipWithIndex.map { case (sample, index) => + val text = Formatter[Struct.Fields] + .format( + sample + .inputObject + .fields + .mapK(WithSource.liftId), + Int.MaxValue, + ) + + CompletionItem.fromHints( + kind = CompletionItemKind.Constant /* todo */, + label = s"Example: ${sample.name}", + insertText = InsertText.JustString(text), + // issue: this doesn't work if the schema already has a Documentation hint. We should remove it first, or do something else. + schema = schema.addHints( + sample.documentation.map(api.Documentation(_)).map(Hints(_)).getOrElse(Hints.empty) + ), + sortTextOverride = Some(s"0_$index"), + ) + } + } + def deprecationString( info: api.Deprecated ): String = { @@ -582,52 +644,10 @@ object CompletionVisitor extends SchemaVisitor[CompletionResolver] { ): CompletionResolver[S] = { // Artificial schema resembling this one. Should be pretty much equivalent. val schema = Schema.struct(fields)(make).addHints(hints).withId(shapeId) - val documentDecoder = Document.Decoder.fromSchema(schema) - - val nodeEncoder = NodeEncoder.derive(schema) val compiledFields = fields.map(field => (field, field.schema.compile(this))) - /* todo: pass this outside of Hints? (visitor context?) */ - val examples = hints - .get(api.Examples) - .foldMap(_.value) - .zipWithIndex - .flatMap { case (example, index) => - val name = example.title - val doc = example.documentation - - for { - input <- example.input - decoded <- documentDecoder.decode(input).toOption - // note: we could've transcoded from Document to Node directly, without the intermediate decoding - // but the examples we suggest should be valid, and this is the only way to ensure that. - encoded = nodeEncoder.toNode(decoded) - - // we're only covering inputs, and operation inputs must be structures. - asObject <- encoded.asStruct - } yield { - val text = Formatter[Struct.Fields] - .format( - asObject - .fields - .mapK(WithSource.liftId), - Int.MaxValue, - ) - - CompletionItem.fromHints( - kind = CompletionItemKind.Constant /* todo */, - label = s"Example: $name", - insertText = InsertText.JustString(text), - // issue: this doesn't work if the schema already has a Documentation hint. We should remove it first, or do something else. - schema = schema.addHints( - doc.map(api.Documentation(_)).map(Hints(_)).getOrElse(Hints.empty) - ), - sortTextOverride = Some(s"0_$index"), - ) - - } - } + val examples = CompletionItem.forInputExamples(schema) structLike( inBody = From 2140d212c0c00335f452318ca1add0177652c7c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Fri, 12 Apr 2024 01:16:36 +0200 Subject: [PATCH 4/4] refact --- .../language/CompletionVisitor.scala | 55 ++++++++++--------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/modules/language-support/src/main/scala/playground/language/CompletionVisitor.scala b/modules/language-support/src/main/scala/playground/language/CompletionVisitor.scala index 06d9d59d..6f0ff13d 100644 --- a/modules/language-support/src/main/scala/playground/language/CompletionVisitor.scala +++ b/modules/language-support/src/main/scala/playground/language/CompletionVisitor.scala @@ -402,37 +402,34 @@ object CompletionItem { val documentDecoder = Document.Decoder.fromSchema(schema) val nodeEncoder = NodeEncoder.derive(schema) - case class InputData( + case class Sample( name: String, documentation: Option[String], inputObject: Struct[Id], ) - val samples: List[InputData] = schema - .hints - .get(api.Examples) - .foldMap(_.value) - .flatMap { example => - val name = example.title - val doc = example.documentation - - for { - input <- example.input - decoded <- documentDecoder.decode(input).toOption - // note: we could've transcoded from Document to Node directly, without the intermediate decoding - // but the examples we suggest should be valid, and this is the only way to ensure that. - encoded = nodeEncoder.toNode(decoded) - - // we're only covering inputs, and operation inputs must be structures. - asObject <- encoded.asStruct - } yield InputData( - name = name, - documentation = doc, - inputObject = asObject, - ) - } + def decodeSample( + example: api.Example + ): Option[Sample] = + for { + input <- example.input + decoded <- documentDecoder.decode(input).toOption + // note: we could've transcoded from Document to Node directly, without the intermediate decoding + // but the examples we suggest should be valid, and this is the only way to ensure that. + encoded = nodeEncoder.toNode(decoded) + + // we're only covering inputs, and operation inputs must be structures. + asObject <- encoded.asStruct + } yield Sample( + name = example.title, + documentation = example.documentation, + inputObject = asObject, + ) - samples.zipWithIndex.map { case (sample, index) => + def completionForSample( + sample: Sample, + index: Int, + ): CompletionItem = { val text = Formatter[Struct.Fields] .format( sample @@ -453,6 +450,14 @@ object CompletionItem { sortTextOverride = Some(s"0_$index"), ) } + + schema + .hints + .get(api.Examples) + .foldMap(_.value) + .flatMap(decodeSample) + .zipWithIndex + .map(completionForSample.tupled) } def deprecationString(