Skip to content

Commit 4cbd9d4

Browse files
authored
Merge pull request #35 from samtkit/cycle-detection
feat(semantic): detect cycles in record fields
2 parents c1373e5 + 4233e6a commit 4cbd9d4

File tree

2 files changed

+149
-1
lines changed

2 files changed

+149
-1
lines changed

semantic/src/main/kotlin/tools/samt/semantic/SemanticModelPostProcessor.kt

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,68 @@ internal class SemanticModelPostProcessor(private val controller: DiagnosticCont
172172
}
173173

174174
private fun checkRecord(record: RecordType) {
175-
record.fields.forEach { checkModelType(it.type) }
175+
record.fields.forEach {
176+
checkModelType(it.type)
177+
checkCycle(record, it)
178+
}
179+
}
180+
181+
private fun checkCycle(rootRecord: RecordType, rootField: RecordType.Field) {
182+
fun impl(field: RecordType.Field, visited: List<Type>, encounteredOptional: Boolean = false) {
183+
val typeReference = (field.type as? ResolvedTypeReference) ?: return
184+
val type = typeReference.type
185+
val record: RecordType
186+
val newVisited: List<Type>
187+
var isOptional = encounteredOptional || typeReference.isOptional
188+
when (type) {
189+
is RecordType -> {
190+
record = type
191+
newVisited = visited + record
192+
}
193+
is AliasType -> {
194+
val reference = type.fullyResolvedType ?: return
195+
val actualType = reference.type
196+
if (actualType !is RecordType) {
197+
return
198+
}
199+
record = actualType
200+
newVisited = visited + listOf(type, actualType)
201+
isOptional = isOptional || reference.isOptional
202+
}
203+
else -> return
204+
}
205+
206+
if (record == rootRecord) {
207+
val declaration = rootField.declaration
208+
val path = newVisited.joinToString("") {
209+
buildString {
210+
append(it.humanReadableName)
211+
if (it is AliasType) {
212+
append(" (typealias)")
213+
}
214+
}
215+
}
216+
if (isOptional) {
217+
declaration.reportWarning(controller) {
218+
message("Record fields should not be cyclical, because they might not be serializable")
219+
highlight("cycle: $path", declaration.location)
220+
}
221+
} else {
222+
declaration.reportError(controller) {
223+
message("Required record fields must not be cyclical, because they cannot be serialized")
224+
highlight("illegal cycle: $path", declaration.location)
225+
}
226+
}
227+
return
228+
}
229+
if (record in visited) {
230+
// we ran into a cycle from a different record
231+
return
232+
}
233+
record.fields.forEach { impl(it, newVisited, isOptional) }
234+
}
235+
236+
impl(rootField, listOf(rootRecord))
176237
}
177238

178239
private fun checkService(service: ServiceType) {

semantic/src/test/kotlin/tools/samt/semantic/SemanticModelTest.kt

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -816,6 +816,93 @@ class SemanticModelTest {
816816
service to emptyList(),
817817
)
818818
}
819+
820+
@Test
821+
fun `cannot have cyclic records`() {
822+
val source = """
823+
package cycles
824+
825+
record Recursive {
826+
recursive: Recursive
827+
}
828+
829+
record IndirectA {
830+
b: IndirectB
831+
}
832+
833+
record IndirectB {
834+
a: IndirectA
835+
}
836+
837+
record ReferencesAll {
838+
r: Recursive
839+
a: IndirectA
840+
b: IndirectB
841+
}
842+
""".trimIndent()
843+
parseAndCheck(
844+
source to List(3) { "Error: Required record fields must not be cyclical, because they cannot be serialized" }
845+
)
846+
}
847+
848+
@Test
849+
fun `cannot have cyclic records with typealiases`() {
850+
val source = """
851+
package cycles
852+
853+
record A {
854+
b: B
855+
}
856+
857+
record B {
858+
c: C
859+
}
860+
861+
typealias C = A
862+
""".trimIndent()
863+
parseAndCheck(
864+
source to List(2) { "Error: Required record fields must not be cyclical, because they cannot be serialized" }
865+
)
866+
}
867+
868+
@Test
869+
fun `can have List or Map of same type`() {
870+
val source = """
871+
package cycles
872+
873+
record A {
874+
children: List<A>
875+
childrenByName: Map<String, A>
876+
}
877+
""".trimIndent()
878+
parseAndCheck(
879+
source to emptyList()
880+
)
881+
}
882+
883+
@Test
884+
fun `cycle with optional type is warning`() {
885+
val source = """
886+
package cycles
887+
888+
record A {
889+
b: B?
890+
}
891+
892+
record B {
893+
a: A
894+
}
895+
896+
record Recursive {
897+
recursive: R
898+
}
899+
900+
typealias R = Recursive?
901+
""".trimIndent()
902+
parseAndCheck(
903+
source to List(3) { "Warning: Record fields should not be cyclical, because they might not be serializable" }
904+
)
905+
}
819906
}
820907

821908
@Nested

0 commit comments

Comments
 (0)