Skip to content

Commit 805d4d4

Browse files
Add test for cyclic imports (#903)
Fixes #760 by giving a more user-friendly error message like the following: <img width="711" alt="Screenshot 2025-03-28 at 12 02 58" src="https://github.com/user-attachments/assets/72ce83a1-29a0-4309-8718-6d965c74ae22" /> ## How it achieves this In a dynamic variable, it keeps a shadow-stack of what `Namer` is currently processing, checking on insertion. This check is moved into the processing of imports so the positioning information is correct.
1 parent 9b71bf9 commit 805d4d4

File tree

4 files changed

+42
-1
lines changed

4 files changed

+42
-1
lines changed

effekt/shared/src/main/scala/effekt/Namer.scala

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import effekt.util.messages.ErrorMessageReifier
1313
import effekt.symbols.scopes.*
1414
import effekt.source.FeatureFlag.supportedByFeatureFlags
1515

16+
import scala.util.DynamicVariable
17+
1618
/**
1719
* The output of this phase: a mapping from source identifier to symbol
1820
*
@@ -39,6 +41,24 @@ object Namer extends Phase[Parsed, NameResolved] {
3941
Some(NameResolved(source, tree, mod))
4042
}
4143

44+
/** Shadow stack of modules currently named, for detecction of cyclic imports */
45+
private val currentlyNaming: DynamicVariable[List[ModuleDecl]] = DynamicVariable(List())
46+
/**
47+
* Run body in a context where we are currently naming `mod`.
48+
* Produces a cyclic import error when this is already the case
49+
*/
50+
private def recursiveProtect[R](mod: ModuleDecl)(body: => R)(using Context): R = {
51+
if (currentlyNaming.value.contains(mod)) {
52+
val cycle = mod :: currentlyNaming.value.takeWhile(_ != mod).reverse
53+
Context.abort(
54+
pretty"""Cyclic import: ${mod.path} depends on itself, via:\n\t${cycle.map(_.path).mkString(" -> ")} -> ${mod.path}""")
55+
} else {
56+
currentlyNaming.withValue(mod :: currentlyNaming.value) {
57+
body
58+
}
59+
}
60+
}
61+
4262
def resolve(mod: Module)(using Context): ModuleDecl = {
4363
val Module(decl, src) = mod
4464
val scope = scopes.toplevel(Context.module.namespace, builtins.rootBindings)
@@ -72,7 +92,8 @@ object Namer extends Phase[Parsed, NameResolved] {
7292
// process all includes, updating the terms and types in scope
7393
val includes = decl.includes collect {
7494
case im @ source.Include(path) =>
75-
val mod = Context.at(im) { importDependency(path) }
95+
// [[recursiveProtect]] is called here so the source position is the recursive import
96+
val mod = Context.at(im) { recursiveProtect(decl){ importDependency(path) } }
7697
Context.annotate(Annotations.IncludedSymbols, im, mod)
7798
mod
7899
}

examples/neg/cyclic_a.check

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
[error] ./examples/neg/cyclic_a.effekt:3:1: Cyclic import: cyclic_a depends on itself, via:
2+
cyclic_a -> cyclic_b -> cyclic_a
3+
import examples/neg/cyclic_b
4+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
5+
[error] ./examples/neg/cyclic_a.effekt:3:1: Cannot compile dependency: ./examples/neg/cyclic_a
6+
import examples/neg/cyclic_b
7+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
8+
[error] ./examples/neg/cyclic_a.effekt:3:1: Cannot compile dependency: ./examples/neg/cyclic_b
9+
import examples/neg/cyclic_b
10+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^

examples/neg/cyclic_a.effekt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module cyclic_a
2+
3+
import examples/neg/cyclic_b
4+
5+
def main() = ()

examples/neg/cyclic_b.effekt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module cyclic_b
2+
3+
import examples/neg/cyclic_a
4+
5+
def main() = ()

0 commit comments

Comments
 (0)