From b0d05a367a3d3b86c43694efff93761586c002fb Mon Sep 17 00:00:00 2001 From: Pavel Marek Date: Thu, 30 Jan 2025 18:03:10 +0100 Subject: [PATCH] Prepare API generator for automatic check (#12175) * Add ApiModificationTest * Fix docs * Add DocsGenerateOrderTest * No signature is generated for empty modules. Or for a module that contains only imports. * Move some methods to DumpTestUtils * Implement BindingSorter * BindingSorter takes care of extension methods * DocsGenerate respects order of bindings * DocsEmitSignatures emits correct markdown format * No signatures for synthetic modules * Conversion methods are sorted after instance methods * Ensure generated docs dir has same structure as src dir * Update Signatures_Spec * Use ScalaConversions --- .../org/enso/compiler/docs/BindingSorter.java | 170 +++++++++++++ .../compiler/docs/DocsEmitSignatures.java | 16 +- .../org/enso/compiler/docs/DocsGenerate.java | 48 +++- .../dump/test/ApiModificationTest.java | 199 +++++++++++++++ .../compiler/dump/test/BindingSorterTest.java | 229 ++++++++++++++++++ .../dump/test/DocsGenerateOrderTest.java | 131 ++++++++++ .../compiler/dump/test/DocsGenerateTest.java | 122 +++++++--- .../compiler/dump/test/DumpTestUtils.java | 74 ++++++ .../enso/scala/wrapper/ScalaConversions.java | 4 + .../org/enso/test/utils/ProjectUtils.java | 2 +- test/Examples_Tests/data/signatures/Meta.md | 68 +++--- test/Examples_Tests/src/Signatures_Spec.enso | 2 +- 12 files changed, 981 insertions(+), 84 deletions(-) create mode 100644 engine/runtime-compiler/src/main/java/org/enso/compiler/docs/BindingSorter.java create mode 100644 engine/runtime-integration-tests/src/test/java/org/enso/compiler/dump/test/ApiModificationTest.java create mode 100644 engine/runtime-integration-tests/src/test/java/org/enso/compiler/dump/test/BindingSorterTest.java create mode 100644 engine/runtime-integration-tests/src/test/java/org/enso/compiler/dump/test/DocsGenerateOrderTest.java create mode 100644 engine/runtime-integration-tests/src/test/java/org/enso/compiler/dump/test/DumpTestUtils.java diff --git a/engine/runtime-compiler/src/main/java/org/enso/compiler/docs/BindingSorter.java b/engine/runtime-compiler/src/main/java/org/enso/compiler/docs/BindingSorter.java new file mode 100644 index 000000000000..d8ec947d0cf5 --- /dev/null +++ b/engine/runtime-compiler/src/main/java/org/enso/compiler/docs/BindingSorter.java @@ -0,0 +1,170 @@ +package org.enso.compiler.docs; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.enso.compiler.core.ir.Module; +import org.enso.compiler.core.ir.module.scope.Definition; +import org.enso.compiler.core.ir.module.scope.Definition.Data; +import org.enso.compiler.core.ir.module.scope.Definition.Type; +import org.enso.compiler.core.ir.module.scope.definition.Method; +import scala.jdk.javaapi.CollectionConverters; + +/** + * Bindings are sorted to categories. Every category is sorted alphabetically. + * Categories are roughly: + * + */ +public final class BindingSorter { + private BindingSorter() {} + + /** + * Returns sorted list of bindings defined on the given {@code moduleIr}. + */ + public static List sortedBindings(Module moduleIr) { + var bindings = CollectionConverters.asJava(moduleIr.bindings()); + var comparator = new BindingComparator(moduleIr); + return bindings.stream().sorted(comparator).toList(); + } + + public static List sortConstructors(List constructors) { + var comparator = new ConstructorComparator(); + return constructors.stream().sorted(comparator).toList(); + } + + + private static int compareTypes(Type type1, Type type2) { + return type1.name().name().compareTo(type2.name().name()); + } + + + private static final class BindingComparator implements java.util.Comparator { + private final Module moduleIr; + private Set typeNames; + + private BindingComparator(Module moduleIr) { + this.moduleIr = moduleIr; + } + + @Override + public int compare(Definition def1, Definition def2) { + return switch (def1) { + case Method method1 when def2 instanceof Method methods -> + compareMethods(method1, methods); + case Type type1 when def2 instanceof Type type2 -> + compareTypes(type1, type2); + case Type type1 when def2 instanceof Method method2 -> compareTypeAndMethod(type1, method2); + case Method method1 when def2 instanceof Type type2 -> + -compareTypeAndMethod(type2, method1); + default -> throw new AssertionError("unexpected type " + def1.getClass()); + }; + } + + private int compareTypeAndMethod(Type type, Method method) { + if (method.typeName().isDefined()) { + if (isExtensionMethod(method)) { + return -1; + } + var typeName = type.name().name(); + var methodTypeName = method.typeName().get().name(); + if (typeName.equals(methodTypeName)) { + return -1; + } else { + return typeName.compareTo(methodTypeName); + } + } + return -1; + } + + + private int compareMethods(Method method1, Method method2) { + return switch (method1) { + case + Method.Explicit explicitMethod1 when method2 instanceof Method.Explicit explicitMethod2 -> { + if (explicitMethod1.isPrivate() != explicitMethod2.isPrivate()) { + if (explicitMethod1.isPrivate()) { + yield 1; + } else { + yield -1; + } + } + if (isExtensionMethod(explicitMethod1) != isExtensionMethod(explicitMethod2)) { + if (isExtensionMethod(explicitMethod1)) { + yield 1; + } else { + yield -1; + } + } + var type1 = explicitMethod1.methodReference().typePointer(); + var type2 = explicitMethod2.methodReference().typePointer(); + if (type1.isDefined() && type2.isDefined()) { + // Both methods are instance or static methods - compare by type name + var typeName1 = type1.get().name(); + var typeName2 = type2.get().name(); + if (typeName1.equals(typeName2)) { + // Methods are defined on the same type + yield explicitMethod1.methodName().name() + .compareTo(explicitMethod2.methodName().name()); + } else { + yield type1.get().name().compareTo(type2.get().name()); + } + } else if (type1.isDefined() && !type2.isDefined()) { + // Instance or static methods on types have precedence over module methods + yield -1; + } else if (!type1.isDefined() && type2.isDefined()) { + yield 1; + } + assert !type1.isDefined() && !type2.isDefined(); + yield explicitMethod1.methodName().name() + .compareTo(explicitMethod2.methodName().name()); + } + // Comparison of conversion methods is not supported. + case Method.Conversion conversion1 when method2 instanceof Method.Conversion conversion2 -> + 0; + case Method.Explicit explicit when method2 instanceof Method.Conversion -> -1; + case Method.Conversion conversion when method2 instanceof Method.Explicit -> 1; + default -> throw new AssertionError( + "Unexpected type: method1=%s, method2=%s".formatted(method1.getClass(), + method2.getClass())); + }; + } + + /** + * An extension method is a method that is defined on a type that is defined outside the + * current module. + */ + private boolean isExtensionMethod(Method method) { + if (method.typeName().isDefined()) { + var typeName = method.typeName().get().name(); + return !typeNamesInModule().contains(typeName); + } + return false; + } + + private Set typeNamesInModule() { + if (typeNames == null) { + typeNames = new HashSet<>(); + moduleIr.bindings().foreach(binding -> { + if (binding instanceof Definition.Type type) { + typeNames.add(type.name().name()); + } + return null; + }); + } + return typeNames; + } + } + + private static final class ConstructorComparator implements java.util.Comparator { + + @Override + public int compare(Data cons1, Data cons2) { + return cons1.name().name().compareTo(cons2.name().name()); + } + } +} diff --git a/engine/runtime-compiler/src/main/java/org/enso/compiler/docs/DocsEmitSignatures.java b/engine/runtime-compiler/src/main/java/org/enso/compiler/docs/DocsEmitSignatures.java index 2d8184b52787..80785d180d6b 100644 --- a/engine/runtime-compiler/src/main/java/org/enso/compiler/docs/DocsEmitSignatures.java +++ b/engine/runtime-compiler/src/main/java/org/enso/compiler/docs/DocsEmitSignatures.java @@ -21,9 +21,13 @@ public boolean visitUnknown(IR ir, PrintWriter w) throws IOException { @Override public boolean visitModule(QualifiedName name, Module module, PrintWriter w) throws IOException { - w.println("## Enso Signatures 1.0"); - w.println("## module " + name); - return true; + if (isEmpty(module)) { + return false; + } else { + w.println("## Enso Signatures 1.0"); + w.println("## module " + name); + return true; + } } @Override @@ -31,6 +35,7 @@ public void visitMethod(Definition.Type t, Method.Explicit m, PrintWriter w) thr if (t != null) { w.append(" - "); } else { + w.append("- "); if (m.typeName().isDefined()) { var fqn = DocsUtils.toFqnOrSimpleName(m.typeName().get()); w.append(fqn + "."); @@ -43,6 +48,7 @@ public void visitMethod(Definition.Type t, Method.Explicit m, PrintWriter w) thr public void visitConversion(Method.Conversion c, PrintWriter w) throws IOException { assert c.typeName().isDefined() : "Conversions need type name: " + c; var fqn = DocsUtils.toFqnOrSimpleName(c.typeName().get()); + w.append("- "); w.append(fqn + "."); w.append(DocsVisit.toSignature(c)); w.append(" -> ").println(fqn); @@ -64,4 +70,8 @@ public void visitConstructor(Definition.Type t, Definition.Data d, PrintWriter w throws IOException { w.println(" - " + DocsVisit.toSignature(d)); } + + private static boolean isEmpty(Module mod) { + return mod.bindings().isEmpty() && mod.exports().isEmpty(); + } } diff --git a/engine/runtime-compiler/src/main/java/org/enso/compiler/docs/DocsGenerate.java b/engine/runtime-compiler/src/main/java/org/enso/compiler/docs/DocsGenerate.java index b1348e8e08c1..365343b66001 100644 --- a/engine/runtime-compiler/src/main/java/org/enso/compiler/docs/DocsGenerate.java +++ b/engine/runtime-compiler/src/main/java/org/enso/compiler/docs/DocsGenerate.java @@ -1,5 +1,8 @@ package org.enso.compiler.docs; +import static org.enso.scala.wrapper.ScalaConversions.asJava; +import static org.enso.scala.wrapper.ScalaConversions.asScala; + import java.io.IOException; import java.io.PrintWriter; import java.util.IdentityHashMap; @@ -10,8 +13,6 @@ import org.enso.compiler.core.ir.module.scope.definition.Method; import org.enso.filesystem.FileSystem; import org.enso.pkg.QualifiedName; -import scala.collection.immutable.Seq; -import scala.jdk.CollectionConverters; /** Generator of documentation for an Enso project. */ public final class DocsGenerate { @@ -32,28 +33,53 @@ public static File write( DocsVisit visitor, org.enso.pkg.Package pkg, Iterable modules) throws IOException { var fs = pkg.fileSystem(); - var docs = fs.getChild(pkg.root(), "docs"); - var api = fs.getChild(docs, "api"); - fs.createDirectories(api); + var apiDir = defaultOutputDir(pkg); + fs.createDirectories(apiDir); for (var module : modules) { + if (module.isSynthetic()) { + continue; + } var ir = module.getIr(); assert ir != null : "need IR for " + module; if (ir.isPrivate()) { continue; } var moduleName = module.getName(); - var dir = createPkg(fs, api, moduleName); + var dir = createDirs(fs, apiDir, stripNamespace(moduleName)); var md = fs.getChild(dir, moduleName.item() + ".md"); try (var mdWriter = fs.newBufferedWriter(md); var pw = new PrintWriter(mdWriter)) { visitModule(visitor, moduleName, ir, pw); } } + return apiDir; + } + + public static File defaultOutputDir(org.enso.pkg.Package pkg) { + var fs = pkg.fileSystem(); + var docs = fs.getChild(pkg.root(), "docs"); + var api = fs.getChild(docs, "api"); return api; } - private static File createPkg(FileSystem fs, File root, QualifiedName pkg) + /** + * Strips namespace part from the given qualified {@code name}. + * + * @param name + */ + private static QualifiedName stripNamespace(QualifiedName name) { + if (!name.isSimple()) { + var path = name.pathAsJava(); + assert path.size() >= 2; + var dropped = path.subList(2, path.size()); + return new QualifiedName(asScala(dropped), name.item()); + } else { + return name; + } + } + + private static File createDirs(FileSystem fs, File root, QualifiedName pkg) throws IOException { var dir = root; for (var item : pkg.pathAsJava()) { @@ -68,7 +94,7 @@ public static void visitModule( var dispatch = DocsDispatch.create(visitor, w); if (dispatch.dispatchModule(moduleName, ir)) { - var moduleBindings = asJava(ir.bindings()); + var moduleBindings = BindingSorter.sortedBindings(ir); var alreadyDispatched = new IdentityHashMap(); for (var b : moduleBindings) { if (alreadyDispatched.containsKey(b)) { @@ -77,7 +103,7 @@ public static void visitModule( switch (b) { case Definition.Type t -> { if (dispatch.dispatchType(t)) { - for (var d : asJava(t.members())) { + for (var d : BindingSorter.sortConstructors(asJava(t.members()))) { if (!d.isPrivate()) { dispatch.dispatchConstructor(t, d); } @@ -111,8 +137,4 @@ public static void visitModule( } } } - - private static Iterable asJava(Seq seq) { - return CollectionConverters.IterableHasAsJava(seq).asJava(); - } } diff --git a/engine/runtime-integration-tests/src/test/java/org/enso/compiler/dump/test/ApiModificationTest.java b/engine/runtime-integration-tests/src/test/java/org/enso/compiler/dump/test/ApiModificationTest.java new file mode 100644 index 000000000000..04591dbfc9f2 --- /dev/null +++ b/engine/runtime-integration-tests/src/test/java/org/enso/compiler/dump/test/ApiModificationTest.java @@ -0,0 +1,199 @@ +package org.enso.compiler.dump.test; + +import static org.enso.test.utils.ContextUtils.defaultContextBuilder; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; + +import java.io.IOException; +import java.util.function.BiConsumer; +import org.graalvm.polyglot.Context; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +/** Tests recognitions of API changes in Enso code. */ +public final class ApiModificationTest { + private static Context ctx; + + @BeforeClass + public static void initCtx() { + ctx = defaultContextBuilder().build(); + } + + @AfterClass + public static void disposeCtx() { + ctx.close(); + ctx = null; + } + + @Test + public void reorderingMethods_DoesNotModifyApi() throws IOException { + var prevSrc = """ + method_1 = 1 + method_2 = 2 + """; + var newSrc = """ + method_2 = 2 + method_1 = 1 + """; + compareSignatures( + prevSrc, + newSrc, + (prevSignature, newSignature) -> { + assertThat( + "Signature not changed when reordering methods", prevSignature, is(newSignature)); + }); + } + + @Test + public void reorderingTypes_DoesNotModifyApi() throws IOException { + var prevSrc = """ + type Type_1 + type Type_2 + """; + var newSrc = """ + type Type_2 + type Type_1 + """; + compareSignatures( + prevSrc, + newSrc, + (prevSignature, newSignature) -> { + assertThat( + "Signature not changed when reordering types", prevSignature, is(newSignature)); + }); + } + + @Test + public void reorderingTypeAndMethod_DoesNotModifyApi() throws IOException { + var prevSrc = """ + type Type + method = 1 + """; + var newSrc = """ + method = 1 + type Type + """; + compareSignatures( + prevSrc, + newSrc, + (prevSignature, newSignature) -> { + assertThat( + "Signature not changed when reordering type and method", + prevSignature, + is(newSignature)); + }); + } + + @Test + public void reorderingMethodsInType_DoesNotModifyApi() throws IOException { + var prevSrc = + """ + type Type + method_1 = 1 + method_2 = 2 + """; + var newSrc = + """ + type Type + method_2 = 2 + method_1 = 1 + """; + compareSignatures( + prevSrc, + newSrc, + (prevSignature, newSignature) -> { + assertThat("Signature not changed when methods in type", prevSignature, is(newSignature)); + }); + } + + @Test + public void reorderingConstructorsInType_DoesNotModifyApi() throws IOException { + var prevSrc = """ + type Type + Cons_2 + Cons_1 + """; + var newSrc = """ + type Type + Cons_1 + Cons_2 + """; + compareSignatures( + prevSrc, + newSrc, + (prevSignature, newSignature) -> { + assertThat( + "Signature not changed when reordering constructors in type", + prevSignature, + is(newSignature)); + }); + } + + @Test + public void addingMethod_ModifiesApi() throws IOException { + var prevSrc = """ + method_1 = 1 + """; + var newSrc = """ + method_1 = 1 + method_2 = 2 + """; + compareSignatures( + prevSrc, + newSrc, + (prevSignature, newSignature) -> { + assertThat("Different signatures", prevSignature, is(not(newSignature))); + assertThat( + "No method_2 in prev signature", prevSignature, not(containsString("method_2"))); + assertThat("method_2 in new signature", newSignature, containsString("method_2")); + }); + } + + @Test + public void renamingMethod_ModifiesApi() throws IOException { + var prevSrc = """ + method = 1 + """; + var newSrc = """ + renamed_method = 2 + """; + compareSignatures( + prevSrc, + newSrc, + (prevSignature, newSignature) -> { + assertThat("Different signatures", prevSignature, is(not(newSignature))); + }); + } + + @Test + public void removingMethod_ModifiesApi() throws IOException { + var prevSrc = """ + method_1 = 1 + method_2 = 2 + """; + var newSrc = """ + method_2 = 2 + """; + compareSignatures( + prevSrc, + newSrc, + (prevSignature, newSignature) -> { + assertThat("Different signatures", prevSignature, is(not(newSignature))); + assertThat(newSignature, not(containsString("method_1"))); + }); + } + + private static void compareSignatures( + String prevSource, String newSource, BiConsumer signatureComparator) + throws IOException { + var modName = "local.Proj.Main"; + var prevSignature = DumpTestUtils.generateSignatures(ctx, prevSource, modName); + var newSignature = DumpTestUtils.generateSignatures(ctx, newSource, modName); + assertThat("Signature was generated", prevSignature.isEmpty(), is(false)); + assertThat("Signature was generated", newSignature.isEmpty(), is(false)); + signatureComparator.accept(prevSignature, newSignature); + } +} diff --git a/engine/runtime-integration-tests/src/test/java/org/enso/compiler/dump/test/BindingSorterTest.java b/engine/runtime-integration-tests/src/test/java/org/enso/compiler/dump/test/BindingSorterTest.java new file mode 100644 index 000000000000..f04df23e55c6 --- /dev/null +++ b/engine/runtime-integration-tests/src/test/java/org/enso/compiler/dump/test/BindingSorterTest.java @@ -0,0 +1,229 @@ +package org.enso.compiler.dump.test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.is; + +import java.util.Arrays; +import java.util.List; +import org.enso.compiler.core.ir.Empty; +import org.enso.compiler.core.ir.Expression; +import org.enso.compiler.core.ir.MetadataStorage; +import org.enso.compiler.core.ir.Module; +import org.enso.compiler.core.ir.Name; +import org.enso.compiler.core.ir.Name.MethodReference; +import org.enso.compiler.core.ir.module.scope.Definition; +import org.enso.compiler.core.ir.module.scope.definition.Method; +import org.enso.compiler.docs.BindingSorter; +import org.enso.persist.Persistance.Reference; +import org.junit.Test; +import scala.Option; +import scala.jdk.javaapi.CollectionConverters; + +public final class BindingSorterTest { + @Test + public void compareTwoModuleMethods() { + var method1 = method(null, "method_a"); + var method2 = method(null, "method_b"); + var sorted = sortBindings(method2, method1); + assertThat("Nothing is dropped", sorted.size(), is(2)); + assertThat("method1 is first", sorted.get(0), is(method1)); + assertThat("method2 is second", sorted.get(1), is(method2)); + } + + @Test + public void comparePrivateAndPublicMethod() { + var publicMethod = method(null, "method_XXX", false, false); + var privMethod = method(null, "AAAA", false, true); + var sorted = sortBindings(privMethod, publicMethod); + assertThat(sorted.get(0), is(publicMethod)); + assertThat(sorted.get(1), is(privMethod)); + } + + @Test + public void compareTypeAndModuleMethod() { + var type = type("My_Type"); + var method = method(null, "AAA"); + var sorted = sortBindings(method, type); + assertThat(sorted.get(0), is(type)); + assertThat(sorted.get(1), is(method)); + } + + @Test + public void compareInstanceMethodAndType() { + var aType = type("A_Type"); + var aTypeMethod = method("A_Type", "method"); + var zType = type("Z_Type"); + var sorted = sortBindings(zType, aTypeMethod, aType); + var expected = List.of(aType, aTypeMethod, zType); + assertSameItems(expected, sorted); + } + + @Test + public void compareInstanceMethods_OnDifferentTypes() { + var method1 = method("A_Type", "method"); + var method2 = method("Z_Type", "method"); + var sorted = sortBindings(method2, method1); + assertThat(sorted.get(0), is(method1)); + assertThat(sorted.get(1), is(method2)); + } + + @Test + public void compareInstanceMethods_InSameType() { + var method1 = method("Type", "method_A"); + var method2 = method("Type", "method_B"); + var sorted = sortBindings(method2, method1); + assertThat(sorted.get(0), is(method1)); + assertThat(sorted.get(1), is(method2)); + } + + @Test + public void compareTypes() { + var type1 = type("AA_Type"); + var type2 = type("XX_Type"); + var sorted = sortBindings(type2, type1); + assertThat(sorted.get(0), is(type1)); + assertThat(sorted.get(1), is(type2)); + } + + @Test + public void compareConstructors_InSameType() { + var constructor1 = constructor("A_Cons"); + var constructor2 = constructor("B_Cons"); + var sorted = sortConstructors(constructor2, constructor1); + assertThat(sorted.get(0), is(constructor1)); + assertThat(sorted.get(1), is(constructor2)); + } + + @Test + public void compareInstanceMethodAndModuleMethod() { + var moduleMethod = method(null, "AA", false, false); + var type = type("My_Type"); + var instanceMethod = method("My_Type", "XX", false, false); + var sorted = sortBindings(instanceMethod, moduleMethod, type); + assertThat(sorted.get(0), is(type)); + assertThat(sorted.get(1), is(instanceMethod)); + assertThat(sorted.get(2), is(moduleMethod)); + } + + @Test + public void compareInstanceMethodAndExtensionMethod() { + var type = type("My_Type"); + var instanceMethod = method("My_Type", "XX"); + var extensionMethod = method("Any", "AA"); + var sorted = sortBindings(extensionMethod, instanceMethod, type); + assertThat(sorted.get(0), is(type)); + assertThat(sorted.get(1), is(instanceMethod)); + assertThat(sorted.get(2), is(extensionMethod)); + } + + @Test + public void compareInstanceMethodAndConversionMethod() { + var type = type("My_Type"); + var instanceMethod = method("My_Type", "AA"); + var conversionMethod = conversionMethod("My_Type", "Any"); + var sorted = sortBindings(conversionMethod, instanceMethod, type); + assertThat(sorted.get(0), is(type)); + assertThat(sorted.get(1), is(instanceMethod)); + assertThat(sorted.get(2), is(conversionMethod)); + } + + @Test + public void compareModuleMethodAndConversionMethod() { + var moduleMethod = method(null, "AA"); + var conversionMethod = conversionMethod("My_Type", "Any"); + var sorted = sortBindings(conversionMethod, moduleMethod); + assertThat(sorted.get(0), is(moduleMethod)); + assertThat(sorted.get(1), is(conversionMethod)); + } + + private static void assertSameItems(List expected, List actual) { + var expectedArr = expected.toArray(); + assertThat(actual, contains(expectedArr)); + } + + private static List sortBindings(Definition... items) { + var modIr = module(items); + return BindingSorter.sortedBindings(modIr); + } + + private static List sortConstructors(Definition.Data... items) { + return BindingSorter.sortConstructors(Arrays.stream(items).toList()); + } + + private static Module module(Definition... bindings) { + var bindingsList = Arrays.asList(bindings); + return new Module( + emptyScalaList(), + emptyScalaList(), + CollectionConverters.asScala(bindingsList).toList(), + false, + null, + new MetadataStorage()); + } + + private static Method.Explicit method(String typeName, String methodName) { + return method(typeName, methodName, false, false); + } + + private static Method.Explicit method( + String typeName, String methodName, boolean isStatic, boolean isPrivate) { + MethodReference methodRef; + if (typeName != null) { + methodRef = + new Name.MethodReference( + Option.apply(name(typeName, false)), + name(methodName, true), + null, + new MetadataStorage()); + } else { + methodRef = + new Name.MethodReference( + Option.empty(), name(methodName, true), null, new MetadataStorage()); + } + Reference bodyRef = Reference.of(empty()); + return new Method.Explicit( + methodRef, bodyRef, isStatic, isPrivate, false, null, new MetadataStorage()); + } + + private static Method.Conversion conversionMethod(String targetTypeName, String sourceTypeName) { + var methodRef = + new Name.MethodReference( + Option.apply(name(targetTypeName, false)), + name("from", true), + null, + new MetadataStorage()); + return new Method.Conversion( + methodRef, name(sourceTypeName, false), empty(), null, new MetadataStorage()); + } + + private static Name name(String nm, boolean isMethod) { + return new Name.Literal(nm, isMethod, null, Option.empty(), new MetadataStorage()); + } + + private static Definition.Data constructor(String name) { + return new Definition.Data( + name(name, false), emptyScalaList(), emptyScalaList(), false, null, new MetadataStorage()); + } + + private static Definition.Type type(String name, List constructors) { + return new Definition.Type( + name(name, false), + emptyScalaList(), + CollectionConverters.asScala(constructors).toList(), + null, + new MetadataStorage()); + } + + private static Definition.Type type(String name) { + return type(name, List.of()); + } + + private static Empty empty() { + return new Empty(null); + } + + private static scala.collection.immutable.List emptyScalaList() { + return scala.collection.immutable.List$.MODULE$.empty(); + } +} diff --git a/engine/runtime-integration-tests/src/test/java/org/enso/compiler/dump/test/DocsGenerateOrderTest.java b/engine/runtime-integration-tests/src/test/java/org/enso/compiler/dump/test/DocsGenerateOrderTest.java new file mode 100644 index 000000000000..7136345faf1b --- /dev/null +++ b/engine/runtime-integration-tests/src/test/java/org/enso/compiler/dump/test/DocsGenerateOrderTest.java @@ -0,0 +1,131 @@ +package org.enso.compiler.dump.test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; + +import java.io.IOException; +import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.List; +import org.enso.compiler.core.IR; +import org.enso.compiler.core.ir.Module; +import org.enso.compiler.core.ir.Name; +import org.enso.compiler.core.ir.module.scope.Definition.Data; +import org.enso.compiler.core.ir.module.scope.Definition.Type; +import org.enso.compiler.core.ir.module.scope.definition.Method.Conversion; +import org.enso.compiler.core.ir.module.scope.definition.Method.Explicit; +import org.enso.compiler.docs.DocsVisit; +import org.enso.pkg.QualifiedName; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** + * IR elements in a module should be visited in a specific order. This test checks that the order is + * correct. + */ +public class DocsGenerateOrderTest { + @ClassRule public static final TemporaryFolder TMP_DIR = new TemporaryFolder(); + + @Test + public void moduleElementsAreVisitedInCorrectOrder() throws IOException { + var projName = "Proj"; + var moduleName = "local." + projName + ".Main"; + var aTypeName = "A_Type"; + var bTypeName = "B_Type"; + var src = + """ + import Standard.Base.Any.Any + + b_module_method = 1 # 7 + + type B_Type # 5 + + Any.extension_method self = 3 # 8 + + type A_Type # 0 + B_Cons # 2 + A_Cons # 1 + b_method self = 1 # 4 + a_method self = 2 # 3 + + B_Type.from _:A_Type = 4 # 9 + a_module_method = 2 # 6 + """; + var projDir = TMP_DIR.newFolder(); + var timestampVisitor = new TimestampVisitor(); + DumpTestUtils.generateDocumentation(projDir.toPath(), "Proj", src, timestampVisitor); + var events = timestampVisitor.events; + List expectedEvents = + List.of( + new VisitedModule(moduleName), + new VisitedType(aTypeName), + new VisitedConstructor(aTypeName, "A_Cons"), + new VisitedConstructor(aTypeName, "B_Cons"), + new VisitedMethod(aTypeName, "a_method"), + new VisitedMethod(aTypeName, "b_method"), + new VisitedType(bTypeName), + new VisitedMethod(null, "a_module_method"), + new VisitedMethod(null, "b_module_method"), + new VisitedMethod(null, "extension_method"), + new VisitedConversion(bTypeName, aTypeName)); + var expectedEventsArr = expectedEvents.toArray(Event[]::new); + assertThat(events, contains(expectedEventsArr)); + } + + private static final class TimestampVisitor implements DocsVisit { + private final List events = new ArrayList<>(); + + @Override + public boolean visitModule(QualifiedName name, Module ir, PrintWriter writer) { + events.add(new VisitedModule(name.toString())); + return true; + } + + @Override + public boolean visitUnknown(IR ir, PrintWriter w) { + return true; + } + + @Override + public void visitMethod(Type t, Explicit m, PrintWriter writer) { + var typeName = t == null ? null : t.name().name(); + events.add(new VisitedMethod(typeName, m.methodName().name())); + } + + @Override + public void visitConversion(Conversion c, PrintWriter w) { + var targetTypeName = c.typeName().get().name(); + var sourceTypeName = ((Name.Literal) c.sourceTypeName()).name(); + events.add(new VisitedConversion(targetTypeName, sourceTypeName)); + } + + @Override + public boolean visitType(Type t, PrintWriter w) { + events.add(new VisitedType(t.name().name())); + return true; + } + + @Override + public void visitConstructor(Type t, Data d, PrintWriter w) { + events.add(new VisitedConstructor(t.name().name(), d.name().name())); + } + } + + /** All names are unqualified */ + private interface Event {} + + private record VisitedModule(String moduleName) implements Event {} + + /** + * @param typeName Can be null if this is a module method + * @param methodName + */ + private record VisitedMethod(String typeName, String methodName) implements Event {} + + private record VisitedConversion(String targetTypeName, String sourceTypeName) implements Event {} + + private record VisitedType(String typeName) implements Event {} + + private record VisitedConstructor(String typeName, String consName) implements Event {} +} diff --git a/engine/runtime-integration-tests/src/test/java/org/enso/compiler/dump/test/DocsGenerateTest.java b/engine/runtime-integration-tests/src/test/java/org/enso/compiler/dump/test/DocsGenerateTest.java index ac20a576897c..e51c541a18dc 100644 --- a/engine/runtime-integration-tests/src/test/java/org/enso/compiler/dump/test/DocsGenerateTest.java +++ b/engine/runtime-integration-tests/src/test/java/org/enso/compiler/dump/test/DocsGenerateTest.java @@ -1,7 +1,10 @@ package org.enso.compiler.dump.test; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.startsWith; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @@ -9,6 +12,7 @@ import java.io.PrintWriter; import java.util.ArrayList; import java.util.List; +import java.util.Set; import org.enso.compiler.Compiler; import org.enso.compiler.core.IR; import org.enso.compiler.core.ir.Module; @@ -16,10 +20,12 @@ import org.enso.compiler.core.ir.module.scope.definition.Method; import org.enso.compiler.docs.DocsGenerate; import org.enso.compiler.docs.DocsVisit; +import org.enso.editions.LibraryName; import org.enso.interpreter.runtime.EnsoContext; import org.enso.pkg.QualifiedName; import org.enso.test.utils.ContextUtils; import org.enso.test.utils.ProjectUtils; +import org.enso.test.utils.SourceModule; import org.graalvm.polyglot.Context; import org.junit.AfterClass; import org.junit.BeforeClass; @@ -225,6 +231,86 @@ public void privateAreHidden() throws Exception { assertEquals("No constructors", 0, v.visitConstructor.size()); } + @Test + public void noSignatureIsGenerated_ForEmptyModule() throws IOException { + var emptyCode = ""; + var modName = "local.Empty.Main"; + var sig = DumpTestUtils.generateSignatures(ctx, emptyCode, modName); + assertTrue("Empty signature for empty module", sig.isEmpty()); + } + + @Test + public void noSignatureIsGenerated_ForModuleContainingOnlyImports() throws IOException { + var codeWithImports = + """ + import Standard.Base.Any.Any + import Standard.Base.Data.Vector.Vector + """; + var modName = "local.Empty.Main"; + var sig = DumpTestUtils.generateSignatures(ctx, codeWithImports, modName); + assertTrue("Empty signature for module with only imports", sig.isEmpty()); + } + + @Test + public void generatedSignature_HasCorrectMarkdownFormat() throws IOException { + var code = + """ + from Standard.Base import all + + module_method = 42 + + type My_Type + Cons x + instance_method self = 42 + + My_Type.static_method = 42 + Any.extension_method = 42 + My_Type.from (that: Integer) = My_Type.Cons that + """; + var modName = "local.Proj.Main"; + var sig = DumpTestUtils.generateSignatures(ctx, code, modName); + sig.lines() + .forEach( + line -> { + assertThat( + "Is heading or a list item", + line, + anyOf(startsWith("#"), startsWith("-"), startsWith(" -"))); + }); + } + + @Test + public void generatedSignaturesForProject_HasSameDirectoryHierarchyAsSources() + throws IOException { + var projName = "Proj"; + var modules = + Set.of( + new SourceModule(QualifiedName.fromString("Main"), "main = 42"), + new SourceModule(QualifiedName.fromString("Subdir.Submodule"), "submodule = 42")); + var projDir = TEMP.newFolder(projName); + ProjectUtils.createProject(projName, modules, projDir.toPath()); + ProjectUtils.generateProjectDocs( + "api", + ContextUtils.defaultContextBuilder(), + projDir.toPath(), + ctx -> { + var ensoCtx = ContextUtils.leakContext(ctx); + var pkg = + ensoCtx + .getPackageRepository() + .getPackageForLibrary(LibraryName.apply("local", projName)); + assertThat(pkg.isDefined(), is(true)); + var signatureOutDir = DocsGenerate.defaultOutputDir(pkg.get()); + assertThat( + "Default output dir for signatures was created", signatureOutDir.exists(), is(true)); + var srcDir = pkg.get().sourceDir(); + assertThat(srcDir.resolve("Main.enso").exists(), is(true)); + assertThat(signatureOutDir.resolve("Main.md").exists(), is(true)); + assertThat(srcDir.resolve("Subdir").resolve("Submodule.enso").exists(), is(true)); + assertThat(signatureOutDir.resolve("Subdir").resolve("Submodule.md").exists(), is(true)); + }); + } + @Test public void vectorWithElements() throws Exception { var code = @@ -309,33 +395,10 @@ public void intersectionTypes() throws Exception { sig); } - private static void generateDocumentation(String name, String code, DocsVisit v) + private static void generateDocumentation(String projectName, String code, DocsVisit v) throws IOException { - var pathCalc = TEMP.newFolder(name); - ProjectUtils.createProject(name, code, pathCalc.toPath()); - ProjectUtils.generateProjectDocs( - "api", - ContextUtils.defaultContextBuilder(), - pathCalc.toPath(), - (context) -> { - var enso = ContextUtils.leakContext(context); - var modules = enso.getTopScope().getModules(); - var optMod = - modules.stream().filter(m -> m.getName().toString().contains(name)).findFirst(); - assertTrue( - "Found " + name + " in " + modules.stream().map(m -> m.getName()).toList(), - optMod.isPresent()); - var mod = optMod.get(); - assertEquals("local." + name + ".Main", mod.getName().toString()); - var ir = mod.getIr(); - assertNotNull("Ir for " + mod + " found", ir); - - try { - DocsGenerate.visitModule(v, mod.getName(), ir, null); - } catch (IOException e) { - throw raise(RuntimeException.class, e); - } - }); + var pathCalc = TEMP.newFolder(projectName); + DumpTestUtils.generateDocumentation(pathCalc.toPath(), projectName, code, v); } private static final class MockVisitor implements DocsVisit { @@ -383,10 +446,5 @@ public void visitConstructor(Definition.Type t, Definition.Data d, PrintWriter w } } - @SuppressWarnings("unchecked") - private static E raise(Class type, Exception t) throws E { - throw (E) t; - } - record TypeAnd(Definition.Type t, IRElement ir) {} } diff --git a/engine/runtime-integration-tests/src/test/java/org/enso/compiler/dump/test/DumpTestUtils.java b/engine/runtime-integration-tests/src/test/java/org/enso/compiler/dump/test/DumpTestUtils.java new file mode 100644 index 000000000000..7af9812852a8 --- /dev/null +++ b/engine/runtime-integration-tests/src/test/java/org/enso/compiler/dump/test/DumpTestUtils.java @@ -0,0 +1,74 @@ +package org.enso.compiler.dump.test; + +import static org.enso.test.utils.ContextUtils.compileModule; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.file.Path; +import org.enso.compiler.docs.DocsGenerate; +import org.enso.compiler.docs.DocsVisit; +import org.enso.pkg.QualifiedName; +import org.enso.test.utils.ContextUtils; +import org.enso.test.utils.ProjectUtils; +import org.graalvm.polyglot.Context; + +final class DumpTestUtils { + private DumpTestUtils() {} + + static void generateDocumentation(Path projDir, String projName, String code, DocsVisit v) + throws IOException { + ProjectUtils.createProject(projName, code, projDir); + ProjectUtils.generateProjectDocs( + "api", + ContextUtils.defaultContextBuilder(), + projDir, + (context) -> { + var enso = ContextUtils.leakContext(context); + var modules = enso.getTopScope().getModules(); + var optMod = + modules.stream().filter(m -> m.getName().toString().contains(projName)).findFirst(); + assertTrue( + "Found " + projName + " in " + modules.stream().map(m -> m.getName()).toList(), + optMod.isPresent()); + var mod = optMod.get(); + assertEquals("local." + projName + ".Main", mod.getName().toString()); + var ir = mod.getIr(); + assertNotNull("Ir for " + mod + " found", ir); + + try { + DocsGenerate.visitModule(v, mod.getName(), ir, null); + } catch (IOException e) { + throw raise(RuntimeException.class, e); + } + }); + } + + /** + * Returns generated signatures as string. Uses {@link org.enso.compiler.docs.DocsEmitSignatures} + * visitor. + * + * @param moduleSrc Source code of the module. + * @param modName FQN of the module. + * @return Signature string for the module. + */ + static String generateSignatures(Context context, String moduleSrc, String modName) + throws IOException { + var modIr = compileModule(context, moduleSrc, modName); + var sigGenerator = DocsVisit.createSignatures(); + var out = new ByteArrayOutputStream(); + var writer = new PrintWriter(out); + var modFqn = QualifiedName.fromString(modName); + DocsGenerate.visitModule(sigGenerator, modFqn, modIr, writer); + writer.flush(); + return out.toString(); + } + + @SuppressWarnings("unchecked") + private static E raise(Class type, Exception t) throws E { + throw (E) t; + } +} diff --git a/lib/java/scala-libs-wrapper/src/main/java/org/enso/scala/wrapper/ScalaConversions.java b/lib/java/scala-libs-wrapper/src/main/java/org/enso/scala/wrapper/ScalaConversions.java index a2020178888f..0abb5f19d513 100644 --- a/lib/java/scala-libs-wrapper/src/main/java/org/enso/scala/wrapper/ScalaConversions.java +++ b/lib/java/scala-libs-wrapper/src/main/java/org/enso/scala/wrapper/ScalaConversions.java @@ -32,6 +32,10 @@ public static List asJava(Seq list) { return CollectionConverters.asJava(list); } + public static scala.collection.immutable.List asScala(List list) { + return CollectionConverters.asScala(list).toList(); + } + @SuppressWarnings("unchecked") public static scala.collection.immutable.List nil() { return (scala.collection.immutable.List) scala.collection.immutable.Nil$.MODULE$; diff --git a/lib/java/test-utils/src/main/java/org/enso/test/utils/ProjectUtils.java b/lib/java/test-utils/src/main/java/org/enso/test/utils/ProjectUtils.java index 40704d9f6b30..fabf6eccc025 100644 --- a/lib/java/test-utils/src/main/java/org/enso/test/utils/ProjectUtils.java +++ b/lib/java/test-utils/src/main/java/org/enso/test/utils/ProjectUtils.java @@ -111,7 +111,7 @@ public static void testProjectRun( /** * Tests running the project located in the given {@code projDir}. Is equal to running {@code enso - * --run }. + * --docs --in-project }. * * @param docsFormat format of the documentation to generate * @param ctxBuilder A context builder that might be initialized with some specific options. diff --git a/test/Examples_Tests/data/signatures/Meta.md b/test/Examples_Tests/data/signatures/Meta.md index c4f8ac6fe8f7..53e39d010ead 100644 --- a/test/Examples_Tests/data/signatures/Meta.md +++ b/test/Examples_Tests/data/signatures/Meta.md @@ -1,47 +1,47 @@ ## Enso Signatures 1.0 ## module Standard.Base.Meta -- type Type - - constructors self -> (Standard.Base.Data.Vector.Vector Standard.Base.Meta.Constructor) - - methods self -> Standard.Base.Data.Vector.Vector - - qualified_name self -> Standard.Base.Data.Text.Text - - name self -> Standard.Base.Data.Text.Text - - find qualified_name:Standard.Base.Data.Text.Text -> Standard.Base.Any.Any - type Atom - - value self -> Standard.Base.Any.Any - - fields self -> (Standard.Base.Data.Vector.Vector Standard.Base.Any.Any) - constructor self -> Standard.Base.Meta.Constructor + - fields self -> (Standard.Base.Data.Vector.Vector Standard.Base.Any.Any) + - value self -> Standard.Base.Any.Any - type Constructor - - value self -> Standard.Base.Function.Function + - declaring_type self -> Standard.Base.Meta.Type - fields self -> (Standard.Base.Data.Vector.Vector Standard.Base.Data.Text.Text) - name self -> Standard.Base.Data.Text.Text - new self fields:(Standard.Base.Data.Vector.Vector|Standard.Base.Data.Array.Array) -> Standard.Base.Any.Any - - declaring_type self -> Standard.Base.Meta.Type -- type Primitive - - value self -> Standard.Base.Any.Any -- type Unresolved_Symbol - - value self -> Standard.Base.Any.Any - - rename self new_name:Standard.Base.Data.Text.Text -> Standard.Base.Meta.Unresolved_Symbol - - name self -> Standard.Base.Data.Text.Text + - value self -> Standard.Base.Function.Function - type Error - value self -> Standard.Base.Any.Any -- type Polyglot - - value self -> Standard.Base.Any.Any - - get_language self -> Standard.Base.Meta.Language -Standard.Base.Any.Any.is_same_object_as self value:Standard.Base.Any.Any -> Standard.Base.Data.Boolean.Boolean -Standard.Base.Any.Any.is_a self typ:Standard.Base.Any.Any -> Standard.Base.Data.Boolean.Boolean -Standard.Base.Error.Error.is_a self typ:Standard.Base.Any.Any -> Standard.Base.Data.Boolean.Boolean -atom_with_hole factory:Standard.Base.Any.Any -> Standard.Base.Any.Any -meta ~value:Standard.Base.Any.Any -> (Standard.Base.Meta.Atom|Standard.Base.Meta.Constructor|Standard.Base.Meta.Primitive|Standard.Base.Meta.Polyglot|Standard.Base.Meta.Unresolved_Symbol|Standard.Base.Meta.Error|Standard.Base.Meta.Type) -is_same_object value_1:Standard.Base.Any.Any value_2:Standard.Base.Any.Any -> Standard.Base.Data.Boolean.Boolean -is_a value:Standard.Base.Any.Any typ:Standard.Base.Any.Any -> Standard.Base.Data.Boolean.Boolean -type_of value:Standard.Base.Any.Any -> Standard.Base.Any.Any -get_annotation target:Standard.Base.Any.Any method:Standard.Base.Any.Any parameter_name:Standard.Base.Any.Any -> Standard.Base.Any.Any - type Language - Java - Unknown -is_atom_constructor ~value:Standard.Base.Any.Any -> Standard.Base.Data.Boolean.Boolean -is_atom value:Standard.Base.Any.Any -> Standard.Base.Data.Boolean.Boolean -is_error value:Standard.Base.Any.Any -> Standard.Base.Data.Boolean.Boolean -is_type value:Standard.Base.Any.Any -> Standard.Base.Data.Boolean.Boolean -is_polyglot value:Standard.Base.Any.Any -> Standard.Base.Data.Boolean.Boolean -Standard.Base.Meta.Type.from that:Standard.Base.Any.Any -> Standard.Base.Meta.Type +- type Polyglot + - get_language self -> Standard.Base.Meta.Language + - value self -> Standard.Base.Any.Any +- type Primitive + - value self -> Standard.Base.Any.Any +- type Type + - constructors self -> (Standard.Base.Data.Vector.Vector Standard.Base.Meta.Constructor) + - find qualified_name:Standard.Base.Data.Text.Text -> Standard.Base.Any.Any + - methods self -> Standard.Base.Data.Vector.Vector + - name self -> Standard.Base.Data.Text.Text + - qualified_name self -> Standard.Base.Data.Text.Text +- type Unresolved_Symbol + - name self -> Standard.Base.Data.Text.Text + - rename self new_name:Standard.Base.Data.Text.Text -> Standard.Base.Meta.Unresolved_Symbol + - value self -> Standard.Base.Any.Any +- atom_with_hole factory:Standard.Base.Any.Any -> Standard.Base.Any.Any +- get_annotation target:Standard.Base.Any.Any method:Standard.Base.Any.Any parameter_name:Standard.Base.Any.Any -> Standard.Base.Any.Any +- is_a value:Standard.Base.Any.Any typ:Standard.Base.Any.Any -> Standard.Base.Data.Boolean.Boolean +- is_atom value:Standard.Base.Any.Any -> Standard.Base.Data.Boolean.Boolean +- is_atom_constructor ~value:Standard.Base.Any.Any -> Standard.Base.Data.Boolean.Boolean +- is_error value:Standard.Base.Any.Any -> Standard.Base.Data.Boolean.Boolean +- is_polyglot value:Standard.Base.Any.Any -> Standard.Base.Data.Boolean.Boolean +- is_same_object value_1:Standard.Base.Any.Any value_2:Standard.Base.Any.Any -> Standard.Base.Data.Boolean.Boolean +- is_type value:Standard.Base.Any.Any -> Standard.Base.Data.Boolean.Boolean +- meta ~value:Standard.Base.Any.Any -> (Standard.Base.Meta.Atom|Standard.Base.Meta.Constructor|Standard.Base.Meta.Primitive|Standard.Base.Meta.Polyglot|Standard.Base.Meta.Unresolved_Symbol|Standard.Base.Meta.Error|Standard.Base.Meta.Type) +- type_of value:Standard.Base.Any.Any -> Standard.Base.Any.Any +- Standard.Base.Any.Any.is_a self typ:Standard.Base.Any.Any -> Standard.Base.Data.Boolean.Boolean +- Standard.Base.Any.Any.is_same_object_as self value:Standard.Base.Any.Any -> Standard.Base.Data.Boolean.Boolean +- Standard.Base.Error.Error.is_a self typ:Standard.Base.Any.Any -> Standard.Base.Data.Boolean.Boolean +- Standard.Base.Meta.Type.from that:Standard.Base.Any.Any -> Standard.Base.Meta.Type diff --git a/test/Examples_Tests/src/Signatures_Spec.enso b/test/Examples_Tests/src/Signatures_Spec.enso index 7218b58b511c..d78c83d855d7 100644 --- a/test/Examples_Tests/src/Signatures_Spec.enso +++ b/test/Examples_Tests/src/Signatures_Spec.enso @@ -8,7 +8,7 @@ add_specs suite_builder = suite_builder.group "Standard.Base Signature Checks" g setup = Api_Setup.create 'Standard' 'Base' [ "--docs=api" ] group_builder.specify "Standard.Base.Meta" <| - snapshot = setup.api_dir / "Standard" / "Base" / "Meta.md" + snapshot = setup.api_dir / "Meta.md" # Instructions to regenerate the expected snapshot can be found at # https://github.com/enso-org/enso/pull/12031#discussion_r1923133269