Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: compute purity/side effects for expressions #785

Merged
merged 4 commits into from
Nov 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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