From 5a3ceda65c099c23c10c6b90dc3c0979f33aa0aa Mon Sep 17 00:00:00 2001 From: Stevan Milic Date: Mon, 27 Jul 2026 21:19:11 +0000 Subject: [PATCH] fix: resolve run output path and propagate main's exit code - `fuse run` passes the executor an absolute path; a bare program name resolved against PATH, not the working directory. - `main() -> i32` sets the process exit status; other return types are wrapped to exit 0. - `fuse build` emits an extensionless executable; `fuse check` prints to stdout. - Monomorphization synthesizes self-instantiations for receiver-syntax recursive methods, which previously called an undefined function. BREAKING CHANGE: executables lose the `.out` suffix, `fuse check` writes to stdout, and exit status is now `main`'s return value. --- .github/workflows/ci.yml | 2 +- examples/list.fuse | 3 - grin/runtime.c | 8 +- src/main/scala/Fuse.scala | 91 ++++++---- src/main/scala/code/Grin.scala | 65 +++++++- src/main/scala/code/MonoSpecialize.scala | 77 ++++++++- src/main/scala/core/TermFold.scala | 105 ++++++++++++ src/test/scala/CompilerTests.scala | 201 ++++++++++++++++++++--- 8 files changed, 492 insertions(+), 60 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 143bd38..c2679d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ env: # Toolchain release used to run the test suite. Pinned rather than tracking # `latest` so a failed release (which publishes an asset-less tag) cannot # turn every PR red. Bump after a release publishes its artifacts. - FUSE_TOOLCHAIN_VERSION: v0.3.4 + FUSE_TOOLCHAIN_VERSION: v0.4.1 jobs: format: diff --git a/examples/list.fuse b/examples/list.fuse index a5375fc..4696af7 100644 --- a/examples/list.fuse +++ b/examples/list.fuse @@ -12,9 +12,6 @@ impl List[A]: Cons(h, t) => List::fold_left(t, f(acc, h), f) Nil => acc - fun append[A](l1: List[A], l2: List[A]) -> List[A] - List::fold_right(l1, l2, (h, t) => Cons(h, t)) - fun sum(l: List[i32]) -> i32 List::fold_right(l, 0, (acc, b) => acc + b) diff --git a/grin/runtime.c b/grin/runtime.c index f2c5960..4a29559 100644 --- a/grin/runtime.c +++ b/grin/runtime.c @@ -11,6 +11,10 @@ extern int64_t _heap_ptr_; int g_argc = 0; char** g_argv = NULL; +/* Compiled Fuse programs guarantee `grinMain` yields the process exit code as + a T_Int64: `main() -> i32` returns its own value, every other `main` return + type is wrapped by the code generator so the result is discarded and 0 is + returned in its place. */ int64_t grinMain(); void __runtime_error(int64_t c){ @@ -28,11 +32,11 @@ int main(int argc, char** argv) { _heap_ptr_ = (int64_t)heap; #endif - grinMain(); + int64_t exit_code = grinMain(); #ifndef USE_BOEHM_GC free(heap); #endif - return 0; + return (int)exit_code; } diff --git a/src/main/scala/Fuse.scala b/src/main/scala/Fuse.scala index c66c2dc..8b63c89 100644 --- a/src/main/scala/Fuse.scala +++ b/src/main/scala/Fuse.scala @@ -8,6 +8,7 @@ import com.monovore.decline.effect.CommandIOApp import core.Context.Error import java.io.{ + ByteArrayOutputStream, File, FileInputStream, FileOutputStream, @@ -42,10 +43,11 @@ object Fuse version = "0.4.0" // x-release-please-version ) { - // File extensions (public for test access) + // File extensions (public for test access). A built program is an + // extensionless executable named after its source, as `cc -o`/`rustc` + // produce; only the intermediate GRIN carries an extension of its own. val FuseFileExtension = "fuse" val FuseGrinExtension = "grin" - val FuseOutputExtension = "out" /** Paths for build artifacts derived from source file. */ case class BuildPaths( @@ -56,11 +58,11 @@ object Fuse object BuildPaths { def fromSource(sourcePath: String): BuildPaths = { - val base = sourcePath.stripSuffix(FuseFileExtension) + val base = sourcePath.stripSuffix(s".$FuseFileExtension") BuildPaths( source = Paths.get(sourcePath), - grin = Paths.get(base + FuseGrinExtension), - output = Paths.get(base + FuseOutputExtension) + grin = Paths.get(s"$base.$FuseGrinExtension"), + output = Paths.get(base) ) } } @@ -127,8 +129,13 @@ object Fuse } /** Shared compile-then-execute pipeline. Builds the Fuse source to a native - * binary, runs the binary via the supplied executor, and removes the `.grin` - * and `.out` intermediates regardless of executor outcome. + * binary, runs the binary via the supplied executor, and removes both the + * `.grin` intermediate and the binary regardless of executor outcome. + * + * The executor receives an absolute path: a source given without a directory + * component builds to a bare program name, which a spawned process resolves + * against `PATH` rather than the working directory, so the binary that was + * just built would not be found. */ def runFile[A]( file: String, @@ -144,7 +151,7 @@ object Fuse cleanupIntermediateFiles(List(paths.grin)) ) result <- EitherT.right[BuildError]( - executor(paths.output, args) + executor(paths.output.toAbsolutePath, args) .guarantee(cleanupIntermediateFiles(List(paths.output))) ) } yield result @@ -180,7 +187,11 @@ object Fuse paths: BuildPaths ): EitherT[IO, BuildError, Unit] = EitherT( - compileFile(command, paths.source.toFile, paths.grin.toFile).map { + compileFile( + command, + paths.source.toFile, + IO(new FileOutputStream(paths.grin.toFile)) + ).map { case Some(error) => Left(FuseCompileError(error)) case None => Right(()) } @@ -216,32 +227,36 @@ object Fuse IO.blocking(Files.deleteIfExists(path)).void } - /** Type check command. */ - def check(command: CheckFile): IO[ExitCode] = { - val program = new File(command.file) - val output = new File( - command.file.stripSuffix(FuseFileExtension) + FuseOutputExtension - ) - compileFile(command, program, output).flatMap { - case Some(error) => IO.println(error).as(ExitCode.Error) - case None => IO.pure(ExitCode.Success) + /** Type check command. The type representation goes to stdout so it can be + * read or piped; checking a file leaves no artifact behind. + */ + def check(command: CheckFile): IO[ExitCode] = + IO(new ByteArrayOutputStream()).flatMap { representation => + compileFile(command, new File(command.file), IO.pure(representation)) + .flatMap { + case Some(error) => IO.println(error).as(ExitCode.Error) + case None => + new String(representation.toByteArray).trim match { + case "" => IO.pure(ExitCode.Success) + case types => IO.println(types).as(ExitCode.Success) + } + } } - } - /** Compile a Fuse file using bracket for resource safety. */ + /** Compile a Fuse file using bracket for resource safety. The destination is + * acquired rather than passed open, so a source that fails validation leaves + * no half-created output behind. + */ def compileFile( command: Command, origin: File, - destination: File + destination: IO[OutputStream] ): IO[Option[Error]] = validateSourceFile(origin).flatMap { case Some(error) => IO.pure(Some(error)) case None => val acquireStreams: IO[(InputStream, OutputStream)] = - ( - IO(new FileInputStream(origin)), - IO(new FileOutputStream(destination)) - ).tupled + (IO(new FileInputStream(origin)), destination).tupled val releaseStreams: ((InputStream, OutputStream)) => IO[Unit] = { case (in, out) => (IO(in.close()), IO(out.close())).tupled @@ -264,30 +279,46 @@ object Fuse } } - /** Validate that a source file exists, is a regular file, and is readable. */ + /** Validate that a source file exists, is a regular file, is readable, and + * carries the `.fuse` extension — the built executable is the source name + * with that extension removed, so a source without it would be overwritten + * by its own build output. + */ def validateSourceFile(file: File): IO[Option[Error]] = IO { - (file.exists, file.isFile, file.canRead) match { - case (false, _, _) => + ( + file.exists, + file.isFile, + file.canRead, + file.getName.endsWith(s".$FuseFileExtension") + ) match { + case (false, _, _, _) => Some( Utils.consoleError( s"file not found: ${file.getPath}", UnknownInfo ) ) - case (_, false, _) => + case (_, false, _, _) => Some( Utils.consoleError( s"not a file: ${file.getPath}", UnknownInfo ) ) - case (_, _, false) => + case (_, _, false, _) => Some( Utils.consoleError( s"file not readable: ${file.getPath}", UnknownInfo ) ) + case (_, _, _, false) => + Some( + Utils.consoleError( + s"not a .$FuseFileExtension source file: ${file.getPath}", + UnknownInfo + ) + ) case _ => None } } diff --git a/src/main/scala/code/Grin.scala b/src/main/scala/code/Grin.scala index 24c6b38..c63c7c1 100644 --- a/src/main/scala/code/Grin.scala +++ b/src/main/scala/code/Grin.scala @@ -20,6 +20,8 @@ import fuse.SpecializedMethodUtils object Grin { val MainFunction = "grinMain" + // Holds a `main` whose result is not an exit code; see `withExitCodeEntry`. + val WrappedMainFunction = "_fuse_main" /** Checks if a De Bruijn-indexed variable is referenced in a term. Used to * detect dead let-bindings for unused closures. @@ -108,7 +110,8 @@ object Grin { (lambdaBindings, partialFunctions) = values.flatten.unzip applyFunction <- buildApply(partialFunctions.flatten) } yield { - val raw = (lambdaBindings.flatten.map(_.show) :+ applyFunction) + val entry = withExitCodeEntry(lambdaBindings.flatten, bindings) + val raw = (entry.map(_.show) :+ applyFunction) .mkString("\n\n") .replaceAll( "([a-zA-Z0-9])#", @@ -124,6 +127,66 @@ object Grin { s.runEmptyA.value } + /** The C runtime uses `grinMain`'s result as the process exit status, so the + * entry point has to yield a `T_Int64`. A `main() -> i32` already does and + * is emitted untouched — its value becomes the exit code. + * + * Every other `main` return type would hand the runtime whatever + * representation it happens to have — a heap pointer, a float, or, for unit, + * no value at all. Those are compiled under `WrappedMainFunction` and called + * from a generated `grinMain` that binds the result — keeping the call, and + * with it the program's effects, alive through GRIN's dead-code passes — and + * returns 0 in its place. + */ + def withExitCodeEntry( + lambdaBindings: List[LambdaBinding], + bindings: List[Bind] + ): List[LambdaBinding] = + ( + mainReturnsExitCode(bindings), + lambdaBindings.exists(_.name == MainFunction) + ) match { + case (false, true) => + lambdaBindings.map { + case LambdaBinding(MainFunction, e) => + LambdaBinding(WrappedMainFunction, e) + case b => b + } :+ exitCodeEntry + case _ => lambdaBindings + } + + /** True when `main` returns `i32`, the one Fuse type whose GRIN + * representation is the bare `T_Int64` the runtime can exit with. + */ + def mainReturnsExitCode(bindings: List[Bind]): Boolean = + bindings.find(_.i == TypeChecker.MainFunction).map(_.b) match { + case Some(TermAbbBind(_, Some(TypeArrow(_, _, _: TypeInt)))) => true + case Some(TermAbbBind(TermAbs(_, _, _, _, Some(_: TypeInt)), _)) => true + case _ => false + } + + /** `grinMain` for a `main` that does not itself produce an exit code. The + * unit argument mirrors the one the code generator gives every nullary Fuse + * function. + */ + def exitCodeEntry: LambdaBinding = + LambdaBinding( + MainFunction, + Abs( + s"${WrappedMainFunction}_arg", + MultiLineExpr( + List( + BindExpr( + s"${WrappedMainFunction}_res <- $WrappedMainFunction 0", + s"${WrappedMainFunction}_res", + Nil + ) + ), + Value("0") + ) + ) + ) + // Strips `[` and `]` from TypeApp identifier names without touching the // bytes inside GRIN string literals of the form `#"..."`. The unguarded // global `replaceAll("[\\[\\]]", "")` previously deleted brackets from diff --git a/src/main/scala/code/MonoSpecialize.scala b/src/main/scala/code/MonoSpecialize.scala index 5e6303c..bd4d3ee 100644 --- a/src/main/scala/code/MonoSpecialize.scala +++ b/src/main/scala/code/MonoSpecialize.scala @@ -20,6 +20,7 @@ import core.TypeChecker.* import core.Types.* import code.GrinUtils.{toContextState, toContextStateOption, getNameFromType} import code.MonoTypes.* +import fuse.SpecializedMethodUtils import parser.Info.Info import parser.Info.UnknownInfo @@ -703,8 +704,15 @@ object MonoSpecialize { ) case false => List() } - finalInsts = (selfInstantiation ::: dedupedInsts) - .distinctBy(inst => (inst.i, inst.tys, inst.term)) + selfMethodInstantiations = selfMethodProjInsts( + binding, + originalBindName, + instTys, + dedupedInsts + ) + finalInsts = + (selfInstantiation ::: selfMethodInstantiations ::: dedupedInsts) + .distinctBy(inst => (inst.i, inst.tys, inst.term)) closureTypesMap = specializedClosureInsts .flatMap(cInst => cInst.tys.headOption.map(ty => cInst.i -> ty)) .toMap @@ -775,6 +783,71 @@ object MonoSpecialize { case _ => false } + /** A method that recurses on a receiver gets no instantiation out of type + * checking: the self-call sits at the method's own type parameter, so + * `Instantiations.build` has no type solution to record and its + * `tys.nonEmpty` guard skips the site. Left alone, the specialized body + * keeps calling the generic method name while the definition is emitted + * under the type-suffixed one, and the backend emits a call to a function + * that was never defined. + * + * One inst per call site, carrying that site's own position, so + * `MonoRewrite.replaceInstantiations` renames exactly that node. Sites + * already covered by a real inst — a call on some other receiver that + * happens to share the method name — are left to it. + * + * The two other self-call shapes are covered elsewhere: qualified calls by + * `containsAssocProjInBinding` above, bare-name calls in top-level `fun` + * bodies by `renameSelfRecursiveBinding`. + */ + def selfMethodProjInsts( + binding: Binding, + originalBindName: String, + instTys: List[Type], + covered: List[Instantiation] + ): List[Instantiation] = binding match { + case TermAbbBind(term, _) => + val methodName = + SpecializedMethodUtils.extractBaseMethodName(originalBindName) + // Insts that already target this same method do not count as coverage: + // they are the self-call, recorded against a name that no longer leads + // anywhere concrete. Only a call resolved to some *other* bind is left + // to its own inst. + val selfTargets = + Set(originalBindName) ++ abstractTraitMethodName(originalBindName) + val coveredInfos = covered.collect { + case Instantiation(i, proj: TermMethodProj, _, _, _) + if !selfTargets.contains(i) => + proj.info + }.toSet + TermFold + .collectMethodProjInfos(term, methodName) + .distinct + .filterNot(coveredInfos.contains) + .map(info => + Instantiation( + originalBindName, + TermMethodProj(info, TermUnit(info), methodName), + instTys, + List(), + Resolution.Resolved(0) + ) + ) + case _ => List() + } + + /** The abstract trait method a trait-instance method implements: `!m#C` for a + * bind named `!m#T#C`. A self-call inside such a bind is recorded against + * this name whenever the receiver is still the instance's type parameter, + * because that is all type checking can see at the call site. + */ + def abstractTraitMethodName(bindName: String): Option[String] = + bindName match { + case Desugar.TypeInstanceMethodPattern(method, _, cls) => + Some(s"$MethodNamePrefix$method$BindTypeSeparator$cls") + case _ => None + } + /** Separate instantiation categories and compute De Bruijn index mapping for * regular type parameters. */ diff --git a/src/main/scala/core/TermFold.scala b/src/main/scala/core/TermFold.scala index e810889..8c463df 100644 --- a/src/main/scala/core/TermFold.scala +++ b/src/main/scala/core/TermFold.scala @@ -301,6 +301,111 @@ object TermFold { fold[Id, Boolean](algebra, term) } + /** Collect the source positions of `TermMethodProj` calls to `methodName`, + * spelled as it appears at the call site rather than as a mangled bind name + * — the receiver-syntax counterpart of `containsAssocProj`. + * + * Positions rather than nodes: the fold hands each callback its children's + * folded results, so the receiver term is no longer reachable at + * `onMethodProj`. A position identifies a call site uniquely, which is all + * callers need to tell one `x.method(..)` from another. + */ + def collectMethodProjInfos(term: Term, methodName: String): List[Info] = { + import cats.Id + val algebra = new TermAlgebra[Id, List[Info]] { + def onVar(info: Info, idx: Int, ctxLen: Int, depth: Int): List[Info] = Nil + def onAbs( + info: Info, + name: String, + ty: Type, + body: List[Info], + retTy: Option[Type], + depth: Int + ): List[Info] = body + def onClosure( + info: Info, + name: String, + ty: Option[Type], + body: List[Info], + depth: Int + ): List[Info] = body + def onApp( + info: Info, + f: List[Info], + arg: List[Info], + depth: Int + ): List[Info] = f ::: arg + def onFix(info: Info, t: List[Info], depth: Int): List[Info] = t + def onMatch( + info: Info, + scrutinee: List[Info], + cases: List[(Pattern, List[Info])], + depth: Int + ): List[Info] = scrutinee ::: cases.flatMap(_._2) + def onLet( + info: Info, + name: String, + t1: List[Info], + t2: List[Info], + depth: Int + ): List[Info] = t1 ::: t2 + def onProj( + info: Info, + t: List[Info], + label: String, + depth: Int + ): List[Info] = t + def onMethodProj( + info: Info, + t: List[Info], + method: String, + depth: Int + ): List[Info] = (method == methodName) match { + case true => info :: t + case false => t + } + def onAssocProj( + info: Info, + ty: Type, + method: String, + depth: Int + ): List[Info] = Nil + def onRecord( + info: Info, + fields: List[(String, List[Info])], + depth: Int + ): List[Info] = fields.flatMap(_._2) + def onTag( + info: Info, + label: String, + t: List[Info], + ty: Type, + depth: Int + ): List[Info] = t + def onAscribe( + info: Info, + t: List[Info], + ty: Type, + depth: Int + ): List[Info] = t + def onTAbs( + info: Info, + name: String, + cls: List[TypeClass], + body: List[Info], + depth: Int + ): List[Info] = body + def onTApp( + info: Info, + t: List[Info], + ty: Type, + depth: Int + ): List[Info] = t + def onLeaf(term: Term, depth: Int): List[Info] = Nil + } + fold[Id, List[Info]](algebra, term) + } + /** Collect TermVars that refer to bindings outside the current term (idx >= * local binder depth). Pattern-bound and lambda-bound vars are excluded. * diff --git a/src/test/scala/CompilerTests.scala b/src/test/scala/CompilerTests.scala index 5508e2e..e5fe075 100644 --- a/src/test/scala/CompilerTests.scala +++ b/src/test/scala/CompilerTests.scala @@ -2157,8 +2157,12 @@ fun main() -> Unit greetings _0 = _prim_string_print #"Hello World" -grinMain _1 = - greetings 0""") +_fuse_main _1 = + greetings 0 + +grinMain _fuse_main_arg = + _fuse_main_res <- _fuse_main 0 + pure 0""") ) } test("build simple sum type") { @@ -2212,8 +2216,12 @@ fun main() -> f32 2.0 + 2.0 """, BuildOutput(""" -grinMain _0 = - _prim_float_add 2.0 2.0""") +_fuse_main _0 = + _prim_float_add 2.0 2.0 + +grinMain _fuse_main_arg = + _fuse_main_res <- _fuse_main 0 + pure 0""") ) } test("build string addition") { @@ -2223,8 +2231,12 @@ fun main() -> str "Hello" + "World" """, BuildOutput(""" -grinMain _0 = - _prim_string_concat #"Hello" #"World"""") +_fuse_main _0 = + _prim_string_concat #"Hello" #"World" + +grinMain _fuse_main_arg = + _fuse_main_res <- _fuse_main 0 + pure 0""") ) } test("build integer subtraction") { @@ -2245,8 +2257,12 @@ fun main() -> f32 2.0 * 2.0 """, BuildOutput(""" -grinMain _0 = - _prim_float_mul 2.0 2.0""") +_fuse_main _0 = + _prim_float_mul 2.0 2.0 + +grinMain _fuse_main_arg = + _fuse_main_res <- _fuse_main 0 + pure 0""") ) } test("build float division") { @@ -2256,8 +2272,12 @@ fun main() -> f32 2.0 / 2.0 """, BuildOutput(""" -grinMain _0 = - _prim_float_div 2.0 2.0""") +_fuse_main _0 = + _prim_float_div 2.0 2.0 + +grinMain _fuse_main_arg = + _fuse_main_res <- _fuse_main 0 + pure 0""") ) } test("build int modulo") { @@ -2293,8 +2313,12 @@ fun main() -> bool 10 == 10 """, BuildOutput(""" -grinMain _0 = - _prim_int_eq 10 10""") +_fuse_main _0 = + _prim_int_eq 10 10 + +grinMain _fuse_main_arg = + _fuse_main_res <- _fuse_main 0 + pure 0""") ) } test("build str not equal") { @@ -2307,8 +2331,12 @@ fun main() -> bool ffi pure _prim_string_ne :: T_String -> T_String -> T_Bool -grinMain _0 = - _prim_string_ne #"Hello" #"World"""") +_fuse_main _0 = + _prim_string_ne #"Hello" #"World" + +grinMain _fuse_main_arg = + _fuse_main_res <- _fuse_main 0 + pure 0""") ) } test("build and") { @@ -2321,10 +2349,14 @@ fun main() -> bool ffi pure _prim_bool_and :: T_Bool -> T_Bool -> T_Bool -grinMain _0 = +_fuse_main _0 = p2 <- _prim_int_ne 1 2 p3 <- _prim_int_eq 3 4 - _prim_bool_and p2 p3""") + _prim_bool_and p2 p3 + +grinMain _fuse_main_arg = + _fuse_main_res <- _fuse_main 0 + pure 0""") ) } test("build or") { @@ -2337,10 +2369,14 @@ fun main() -> bool ffi pure _prim_bool_or :: T_Bool -> T_Bool -> T_Bool -grinMain _0 = +_fuse_main _0 = p2 <- _prim_int_ne 1 2 p3 <- _prim_int_eq 3 4 - _prim_bool_or p2 p3""") + _prim_bool_or p2 p3 + +grinMain _fuse_main_arg = + _fuse_main_res <- _fuse_main 0 + pure 0""") ) } test("build inline lambda with type annotation") { @@ -2423,9 +2459,13 @@ fun main() -> Unit identity'str v0 = pure v0 -grinMain _1 = +_fuse_main _1 = s2 <- identity'str #"Hello World" _prim_string_print s2 + +grinMain _fuse_main_arg = + _fuse_main_res <- _fuse_main 0 + pure 0 """) ) } @@ -4628,6 +4668,125 @@ grinMain _9 = class CompilerExecTests extends CompilerTests { import CompilerTests.* + // A source given without a directory component builds to a bare program + // name, which a spawned process resolves against PATH instead of the working + // directory. The runner must hand the executor a path that unambiguously + // points at the binary it just built. + test("execute receives an absolute path to the built binary") { + import cats.effect.unsafe.implicits.global + val result = createTempFuseFile(""" +fun main() -> i32 + 0 + """) + .use { fusePath => + Fuse.runFile( + fusePath.toString, + Nil, + false, + (exe: Path, _: List[String]) => + IO.pure((exe.isAbsolute, Files.isExecutable(exe))) + ) + } + .unsafeRunSync() + assertEquals(result, Right((true, true))) + } + + test("execute generic method recursing through itself") { + fuse( + """ +type List[T]: + Cons(h: T, t: List[T]) + Nil + +impl List[T]: + fun append(self, other: List[T]) -> List[T] + match self: + Cons(h, t) => Cons(h, t.append(other)) + Nil => other + +fun main() -> i32 + let l1 = Cons(1, Nil) + let l2 = Cons(2, Nil) + let l3 = l1.append(l2) + match l3: + Cons(h, t) => h + Nil => 0 + """, + ExecutableOutput("", expectedExitCode = 1) + ) + } + + test("execute trait instance method recursing through itself") { + fuse( + """ +trait Monad[A]: + fun flat_map[B](self, f: A -> Self[B]) -> Self[B]; + +type List[T]: + Cons(h: T, t: List[T]) + Nil + +impl List[T]: + fun append(self, other: List[T]) -> List[T] + match self: + Cons(h, t) => Cons(h, t.append(other)) + Nil => other + +impl Monad for List[T]: + fun flat_map[B](self, f: T -> List[B]) -> List[B] + match self: + Cons(h, t) => f(h).append(t.flat_map(f)) + Nil => Nil[B] + +fun main() -> i32 + let l = Cons(1, Cons(2, Nil)) + let doubled = l.flat_map(v => Cons(v, Cons(v * 10, Nil))) + match doubled: + Cons(h, t) => h + Nil => 0 + """, + ExecutableOutput("", expectedExitCode = 1) + ) + } + + test("execute stdlib list append") { + fuse( + """ +fun main() -> i32 + let l1 = Cons(1, Cons(2, Nil)) + let l2 = Cons(3, Nil) + let joined = l1.append(l2) + _print(int_to_str(joined.length())) + joined.head_or(0) + """, + ExecutableOutput("3", expectedExitCode = 1, includeStdlib = true) + ) + } + + test("execute exits with the value main returns") { + fuse( + """ +fun main() -> i32 + _print("failing") + 3 + """, + ExecutableOutput("failing", expectedExitCode = 3) + ) + } + + // A `main` that yields anything but an i32 has no exit code to give, so the + // generated entry point discards its result and exits 0 — while the effects + // it performed must survive GRIN's dead-code passes. + test("execute main without an exit code runs effects and exits 0") { + fuse( + """ +fun main() -> Unit + _print("effect ran") + """, + ExecutableOutput("effect ran", expectedExitCode = 0) + ) + } + test("execute generic phantom in return") { fuse( """ @@ -6374,7 +6533,7 @@ object CompilerTests { dir } - private def createTempFuseFile(code: String): Resource[IO, Path] = { + def createTempFuseFile(code: String): Resource[IO, Path] = { val acquire = IO { val tempFile = Files.createTempFile(testTempDir, "test-", s".$FuseFileExtension") @@ -6386,7 +6545,7 @@ object CompilerTests { val baseName = path.toString.stripSuffix(s".$FuseFileExtension") Files.deleteIfExists(path) Files.deleteIfExists(Paths.get(baseName + s".$FuseGrinExtension")) - Files.deleteIfExists(Paths.get(baseName + s".$FuseOutputExtension")) + Files.deleteIfExists(Paths.get(baseName)) }.void Resource.make(acquire)(release) }