Skip to content

Dependency Loops

This recipe shows how to detect recursion or dependency cycles during validation.

Pegium's arithmetics example already contains a concrete version of this pattern: function definitions may call each other, but recursive cycles are not allowed.

What is the problem?

Cycle detection is rarely a property of one node in isolation. You usually need to inspect a larger graph:

  • function calls
  • imports between files
  • type dependencies
  • state transitions

That is why this kind of check often belongs on the root model node rather than on the individual reference node.

Two common cases

Simple parent-style relations

If the dependency shape is basically 1:n, you can often detect cycles by walking one chain while remembering the visited nodes. Typical examples are parent links or superclass chains.

General graphs

If the dependency shape is really n:m, a graph-oriented approach is usually clearer. Function calls, imports, and many dependency networks fall into that category.

That is the case in arithmetics, where one definition may call several other definitions and may itself be called from many places.

A living example

In examples/arithmetics/src/validation/ArithmeticsValidator.cpp, recursion is checked in checkFunctionRecursion(...).

The validator first registers a model-level check:

registry.registerChecks(
    {pegium::validation::ValidationRegistry::makeValidationCheck<
         &ArithmeticsValidator::checkDivByZero>(validator),
     pegium::validation::ValidationRegistry::makeValidationCheck<
         &ArithmeticsValidator::checkUniqueParameters>(validator),
     pegium::validation::ValidationRegistry::makeValidationCheck<
         &ArithmeticsValidator::checkNormalizable>(validator),
     pegium::validation::ValidationRegistry::makeValidationCheck<
         &ArithmeticsValidator::checkUniqueDefinitions>(validator),
     pegium::validation::ValidationRegistry::makeValidationCheck<
         &ArithmeticsValidator::checkFunctionRecursion>(validator),
     pegium::validation::ValidationRegistry::makeValidationCheck<
         &ArithmeticsValidator::checkMatchingParameters>(validator)});

checkFunctionRecursion(...) then gets the whole Module, which is the right level to see all definitions and calls together.

Step 1: build the dependency view from the AST

The example walks each definition, collects nested FunctionCall nodes, and follows resolved targets:

for (const auto &statement : module.statements) {
  const auto *definition = dynamic_cast<const Definition *>(statement.get());
  if (definition == nullptr) {
    continue;
  }

  auto remainingCalls =
      get_not_traversed_nested_calls(*definition, traversedFunctions);
  // ...
}

Because linking already ran before validation, the validator can work from resolved target definitions instead of raw reference text.

Step 2: detect the cycle

The same validator then walks those call relationships and records the recursive paths it finds. Whether you use a visited-set walk, a DFS with visitation states, or a graph library depends on the shape of your problem.

The important part is that the validation is attached to the whole model, not to one isolated reference.

Step 3: report the most precise diagnostic you can

Once a cycle is found, the example reports the error on the individual call sites participating in that cycle:

for (const auto &cycle : callCycles) {
  const auto cycleMessage = print_cycle(cycle, callsTree);
  for (const auto &entry : iterate_back(cycle, callsTree)) {
    accept
        .error(*entry.call,
               std::format("Recursion is not allowed [{}]", cycleMessage))
        .property<&FunctionCall::func>();
  }
}

That pattern is worth keeping in mind: compute globally, but attach the diagnostic to the smallest useful node or property.

Loop detection versus dependency ordering

Some domains need more than "is there a cycle?".

If the dependency graph is acyclic, you may also want a dependency order for later processing, code generation, or evaluation. The same graph view that helps with cycle detection is often the right basis for that second step as well.

When to use this pattern

This pattern is a good fit when:

  • the rule depends on relationships between many nodes
  • one node can depend on several others
  • a local validator method would not have enough context
  • the best diagnostic still needs to point back to one concrete node