Skip to content

Commit

Permalink
feat: compute purity/side effects for expressions (#785)
Browse files Browse the repository at this point in the history
### Summary of Changes

The purity computer can now compute the purity for arbitrary expressions
(instead of just calls). This is needed, for example, for #15.
  • Loading branch information
lars-reimann authored Nov 20, 2023
1 parent b09bb3a commit 9ed1c08
Show file tree
Hide file tree
Showing 11 changed files with 348 additions and 108 deletions.
27 changes: 27 additions & 0 deletions docs/development/call-graph-testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Call Graph Testing

Call graph tests are data-driven instead of being specified explicitly. This document explains how to add a new call
graph test.

## Adding a call graph test

1. Create a new file with the extension `.sdstest` in the `tests/resources/call graph` directory or any subdirectory.
Give the file a descriptive name, since the file name becomes part of the test name.

!!! tip "Skipping a test"

If you want to skip a test, add the prefix `skip-` to the file name.

2. Add the Safe-DS code that you want to test to the file.
3. Surround calls or callables for which you want to compute a call graph with test markers, e.g. `»f()«`. Add a
comment in the preceding line with the following format:
```ts
// $TEST$ ["f", "$blockLambda", "$expressionLambda", "undefined"]
```
The comment must contain an array with the names of the callables that are expected to be called. The order must
match the actual call order. The names must be:
* The quoted name of a named callable, e.g. `"f"`.
* The string `"$blockLambda"` for a block lambda.
* The string `"$expressionLambda"` for an expression lambda.
* The string `"undefined"` for an undefined callable.
4. Run the tests. The test runner will automatically pick up the new test.
4 changes: 2 additions & 2 deletions docs/development/formatting-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ formatting test.

## Adding a formatting test

1. Create a new file with extension `.sdstest` in the `tests/resources/formatting` directory or any
subdirectory. Give the file a descriptive name, since the file name becomes part of the test name.
1. Create a new file with the extension `.sdstest` in the `tests/resources/formatting` directory or any subdirectory.
Give the file a descriptive name, since the file name becomes part of the test name.

!!! tip "Skipping a test"

Expand Down
2 changes: 1 addition & 1 deletion docs/development/grammar-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ test.

## Adding a grammar test

1. Create a new file with extension `.sdstest` in the `tests/resources/grammar` directory or any subdirectory. Give
1. Create a new file with the extension `.sdstest` in the `tests/resources/grammar` directory or any subdirectory. Give
the file a descriptive name, since the file name becomes part of the test name.

!!! note "Naming convention"
Expand Down
41 changes: 0 additions & 41 deletions docs/development/langium-quickstart.md

This file was deleted.

8 changes: 4 additions & 4 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,14 @@ nav:
- stdlib/README.md
- safeds.lang: stdlib/safeds_lang.md
- Development:
- Call Graph Testing: development/call-graph-testing.md
- Formatting Testing: development/formatting-testing.md
- Generation Testing: development/generation-testing.md
- Grammar Testing: development/grammar-testing.md
- Partial Evaluation Testing: development/partial-evaluation-testing.md
- Scoping Testing: development/scoping-testing.md
- Typing Testing: development/typing-testing.md
- Partial Evaluation Testing: development/partial-evaluation-testing.md
- Validation Testing: development/validation-testing.md
- Generation Testing: development/generation-testing.md
- Formatting Testing: development/formatting-testing.md
- Langium Quickstart: development/langium-quickstart.md

# Configuration of MkDocs & Material for MkDocs --------------------------------

Expand Down
4 changes: 2 additions & 2 deletions packages/safe-ds-lang/src/language/helpers/astUtils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { AstNode, hasContainerOfType } from 'langium';

/**
* Returns whether the inner node is contained in the outer node. If the nodes are equal, this function returns `true`.
* Returns whether the inner node is contained in the outer node or equal to it.
*/
export const isContainedIn = (inner: AstNode | undefined, outer: AstNode | undefined): boolean => {
export const isContainedInOrEqual = (inner: AstNode | undefined, outer: AstNode | undefined): boolean => {
return hasContainerOfType(inner, (it) => it === outer);
};
113 changes: 94 additions & 19 deletions packages/safe-ds-lang/src/language/purity/safe-ds-purity-computer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { type AstNode, type AstNodeLocator, EMPTY_STREAM, getDocument, Stream, WorkspaceCache } from 'langium';
import {
type AstNode,
type AstNodeLocator,
EMPTY_STREAM,
getContainerOfType,
getDocument,
Stream,
WorkspaceCache,
} from 'langium';
import { isEmpty } from '../../helpers/collectionUtils.js';
import type { SafeDsCallGraphComputer } from '../flow/safe-ds-call-graph-computer.js';
import type { SafeDsServices } from '../safe-ds-module.js';
Expand All @@ -9,11 +17,20 @@ import {
OtherImpurityReason,
PotentiallyImpureParameterCall,
} from './model.js';
import { isSdsFunction, SdsCall, SdsCallable, SdsFunction, SdsParameter } from '../generated/ast.js';
import {
isSdsFunction,
isSdsLambda,
SdsCall,
SdsCallable,
SdsExpression,
SdsFunction,
SdsParameter,
} from '../generated/ast.js';
import { EvaluatedEnumVariant, ParameterSubstitutions, StringConstant } from '../partialEvaluation/model.js';
import { SafeDsAnnotations } from '../builtins/safe-ds-annotations.js';
import { SafeDsImpurityReasons } from '../builtins/safe-ds-enums.js';
import { getParameters } from '../helpers/nodeProperties.js';
import { isContainedInOrEqual } from '../helpers/astUtils.js';

export class SafeDsPurityComputer {
private readonly astNodeLocator: AstNodeLocator;
Expand All @@ -32,45 +49,95 @@ export class SafeDsPurityComputer {
this.reasonsCache = new WorkspaceCache(services.shared);
}

// We need separate methods for callables and expressions because lambdas are both. The caller must decide whether
// the lambda should get "executed" (***Callable methods) when computing the impurity reasons or not (***Expression
// methods).

/**
* Returns whether the given callable is pure.
*
* @param node
* The callable to check.
*
* @param substitutions
* The parameter substitutions to use. These are **not** the argument of a call, but the values of the parameters
* of any containing callables, i.e. the context of the node.
*/
isPureCallable(node: SdsCallable, substitutions = NO_SUBSTITUTIONS): boolean {
return isEmpty(this.getImpurityReasonsForCallable(node, substitutions));
}

/**
* Returns whether the given expression is pure.
*
* @param node
* The expression to check.
*
* @param substitutions
* The parameter substitutions to use. These are **not** the argument of a call, but the values of the parameters
* of any containing callables, i.e. the context of the node.
*/
isPureExpression(node: SdsExpression, substitutions = NO_SUBSTITUTIONS): boolean {
return isEmpty(this.getImpurityReasonsForExpression(node, substitutions));
}

/**
* Returns whether the given callable has side effects.
*
* @param node
* The callable to check.
*
* @param substitutions
* The parameter substitutions to use. These are **not** the argument of a call, but the values of the parameters
* of any containing callables, i.e. the context of the node.
*/
callableHasSideEffects(node: SdsCallable, substitutions = NO_SUBSTITUTIONS): boolean {
return this.getImpurityReasonsForCallable(node, substitutions).some((it) => it.isSideEffect);
}

/**
* Returns whether the given call/callable is pure.
* Returns whether the given expression has side effects.
*
* @param node
* The call/callable to check.
* The expression to check.
*
* @param substitutions
* The parameter substitutions to use. These are **not** the argument of the call, but the values of the parameters
* of any containing callables, i.e. the context of the call/callable.
* The parameter substitutions to use. These are **not** the argument of a call, but the values of the parameters
* of any containing callables, i.e. the context of the node.
*/
isPure(node: SdsCall | SdsCallable, substitutions = NO_SUBSTITUTIONS): boolean {
return isEmpty(this.getImpurityReasons(node, substitutions));
expressionHasSideEffects(node: SdsExpression, substitutions = NO_SUBSTITUTIONS): boolean {
return this.getImpurityReasonsForExpression(node, substitutions).some((it) => it.isSideEffect);
}

/**
* Returns whether the given call/callable has side effects.
* Returns the reasons why the given callable is impure.
*
* @param node
* The call/callable to check.
* The callable to check.
*
* @param substitutions
* The parameter substitutions to use. These are **not** the argument of the call, but the values of the parameters
* of any containing callables, i.e. the context of the call/callable.
* The parameter substitutions to use. These are **not** the argument of a call, but the values of the parameters
* of any containing callables, i.e. the context of the node.
*/
hasSideEffects(node: SdsCall | SdsCallable, substitutions = NO_SUBSTITUTIONS): boolean {
return this.getImpurityReasons(node, substitutions).some((it) => it.isSideEffect);
getImpurityReasonsForCallable(node: SdsCallable, substitutions = NO_SUBSTITUTIONS): ImpurityReason[] {
return this.getImpurityReasons(node, substitutions);
}

/**
* Returns the reasons why the given call/callable is impure.
* Returns the reasons why the given expression is impure.
*
* @param node
* The call/callable to check.
* The expression to check.
*
* @param substitutions
* The parameter substitutions to use. These are **not** the argument of the call, but the values of the parameters
* of any containing callables, i.e. the context of the call/callable.
* The parameter substitutions to use. These are **not** the argument of a call, but the values of the parameters
* of any containing callables, i.e. the context of the node.
*/
getImpurityReasons(node: SdsCall | SdsCallable, substitutions = NO_SUBSTITUTIONS): ImpurityReason[] {
getImpurityReasonsForExpression(node: SdsExpression, substitutions = NO_SUBSTITUTIONS): ImpurityReason[] {
return this.getExecutedCallsInExpression(node).flatMap((it) => this.getImpurityReasons(it, substitutions));
}

private getImpurityReasons(node: SdsCall | SdsCallable, substitutions = NO_SUBSTITUTIONS): ImpurityReason[] {
const key = this.getNodeId(node);
return this.reasonsCache.get(key, () => {
return this.callGraphComputer
Expand All @@ -87,6 +154,14 @@ export class SafeDsPurityComputer {
});
}

private getExecutedCallsInExpression(expression: SdsExpression): SdsCall[] {
return this.callGraphComputer.getAllContainedCalls(expression).filter((it) => {
// Keep only calls that are not contained in a lambda inside the expression
const containingLambda = getContainerOfType(it, isSdsLambda);
return !containingLambda || !isContainedInOrEqual(containingLambda, expression);
});
}

private getImpurityReasonsForFunction(node: SdsFunction): Stream<ImpurityReason> {
return this.builtinAnnotations.streamImpurityReasons(node).flatMap((it) => {
switch (it.variant) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ import {
SdsTypeArgument,
SdsYield,
} from '../generated/ast.js';
import { isContainedIn } from '../helpers/astUtils.js';
import { isContainedInOrEqual } from '../helpers/astUtils.js';
import {
getAbstractResults,
getAnnotationCallTarget,
Expand Down Expand Up @@ -336,7 +336,7 @@ export class SafeDsScopeProvider extends DefaultScopeProvider {
const containingStatement = getContainerOfType(node.$container, isSdsStatement);

let placeholders: Iterable<SdsPlaceholder>;
if (!containingCallable || isContainedIn(containingStatement, containingCallable)) {
if (!containingCallable || isContainedInOrEqual(containingStatement, containingCallable)) {
placeholders = this.placeholdersUpToStatement(containingStatement);
} else {
// Placeholders are further away than the parameters
Expand Down
Loading

0 comments on commit 9ed1c08

Please sign in to comment.