Skip to content

Commit 98acdde

Browse files
authored
feat: add endless recursion as an impurity reason (#788)
### Summary of Changes Endless recursion is now included in the inferred impurity reasons of an expression/callable.
1 parent 6f45dc4 commit 98acdde

29 files changed

+237
-305
lines changed

packages/safe-ds-lang/src/language/flow/safe-ds-call-graph-computer.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,13 @@ export class SafeDsCallGraphComputer {
124124
const call = this.createSyntheticCallForCall(node, substitutions);
125125
return this.getCallGraphWithRecursionCheck(call, []);
126126
} else {
127+
const children = this.getExecutedCallsInCallable(node, substitutions).map((it) => {
128+
return this.getCallGraphWithRecursionCheck(it, []);
129+
});
127130
return new CallGraph(
128131
node,
129-
this.getExecutedCallsInCallable(node, substitutions).map((it) => {
130-
return this.getCallGraphWithRecursionCheck(it, []);
131-
}),
132+
children,
133+
children.some((it) => it.isRecursive),
132134
);
133135
}
134136
}

packages/safe-ds-lang/src/language/generation/safe-ds-python-generator.ts

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,7 @@ export class SafeDsPythonGenerator {
359359
}
360360

361361
private generateBlock(block: SdsBlock, frame: GenerationInfoFrame): CompositeGeneratorNode {
362-
let statements = getStatements(block).filter((stmt) => this.hasStatementEffect(stmt));
362+
let statements = getStatements(block).filter((stmt) => this.purityComputer.statementDoesSomething(stmt));
363363
if (statements.length === 0) {
364364
return traceToNode(block)('pass');
365365
}
@@ -368,21 +368,6 @@ export class SafeDsPythonGenerator {
368368
})!;
369369
}
370370

371-
private hasStatementEffect(statement: SdsStatement): boolean {
372-
if (isSdsAssignment(statement)) {
373-
const assignees = getAssignees(statement);
374-
return (
375-
assignees.some((value) => !isSdsWildcard(value)) ||
376-
(statement.expression !== undefined &&
377-
this.purityComputer.expressionHasSideEffects(statement.expression))
378-
);
379-
} else if (isSdsExpressionStatement(statement)) {
380-
return this.purityComputer.expressionHasSideEffects(statement.expression);
381-
}
382-
/* c8 ignore next */
383-
return false;
384-
}
385-
386371
private generateStatement(statement: SdsStatement, frame: GenerationInfoFrame): CompositeGeneratorNode {
387372
if (isSdsAssignment(statement)) {
388373
return traceToNode(statement)(this.generateAssignment(statement, frame));

packages/safe-ds-lang/src/language/purity/model.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,23 @@ export class PotentiallyImpureParameterCall extends ImpurityReason {
100100
}
101101
}
102102

103+
/**
104+
* A function contains a call that leads to endless recursion.
105+
*/
106+
class EndlessRecursionClass extends ImpurityReason {
107+
override isSideEffect = true;
108+
109+
override equals(other: unknown): boolean {
110+
return other instanceof EndlessRecursionClass;
111+
}
112+
113+
override toString(): string {
114+
return 'Endless recursion';
115+
}
116+
}
117+
118+
export const EndlessRecursion = new EndlessRecursionClass();
119+
103120
/**
104121
* A function is impure due to some reason that is not covered by the other impurity reasons.
105122
*/

packages/safe-ds-lang/src/language/purity/safe-ds-purity-computer.ts

Lines changed: 80 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,30 @@ import { isEmpty } from '../../helpers/collectionUtils.js';
1111
import type { SafeDsCallGraphComputer } from '../flow/safe-ds-call-graph-computer.js';
1212
import type { SafeDsServices } from '../safe-ds-module.js';
1313
import {
14+
EndlessRecursion,
1415
FileRead,
1516
FileWrite,
1617
type ImpurityReason,
1718
OtherImpurityReason,
1819
PotentiallyImpureParameterCall,
1920
} from './model.js';
2021
import {
22+
isSdsAssignment,
23+
isSdsExpressionStatement,
2124
isSdsFunction,
2225
isSdsLambda,
26+
isSdsWildcard,
2327
SdsCall,
2428
SdsCallable,
2529
SdsExpression,
2630
SdsFunction,
2731
SdsParameter,
32+
SdsStatement,
2833
} from '../generated/ast.js';
2934
import { EvaluatedEnumVariant, ParameterSubstitutions, StringConstant } from '../partialEvaluation/model.js';
3035
import { SafeDsAnnotations } from '../builtins/safe-ds-annotations.js';
3136
import { SafeDsImpurityReasons } from '../builtins/safe-ds-enums.js';
32-
import { getParameters } from '../helpers/nodeProperties.js';
37+
import { getAssignees, getParameters } from '../helpers/nodeProperties.js';
3338
import { isContainedInOrEqual } from '../helpers/astUtils.js';
3439

3540
export class SafeDsPurityComputer {
@@ -63,7 +68,7 @@ export class SafeDsPurityComputer {
6368
* The parameter substitutions to use. These are **not** the argument of a call, but the values of the parameters
6469
* of any containing callables, i.e. the context of the node.
6570
*/
66-
isPureCallable(node: SdsCallable, substitutions = NO_SUBSTITUTIONS): boolean {
71+
isPureCallable(node: SdsCallable | undefined, substitutions = NO_SUBSTITUTIONS): boolean {
6772
return isEmpty(this.getImpurityReasonsForCallable(node, substitutions));
6873
}
6974

@@ -77,7 +82,7 @@ export class SafeDsPurityComputer {
7782
* The parameter substitutions to use. These are **not** the argument of a call, but the values of the parameters
7883
* of any containing callables, i.e. the context of the node.
7984
*/
80-
isPureExpression(node: SdsExpression, substitutions = NO_SUBSTITUTIONS): boolean {
85+
isPureExpression(node: SdsExpression | undefined, substitutions = NO_SUBSTITUTIONS): boolean {
8186
return isEmpty(this.getImpurityReasonsForExpression(node, substitutions));
8287
}
8388

@@ -91,7 +96,7 @@ export class SafeDsPurityComputer {
9196
* The parameter substitutions to use. These are **not** the argument of a call, but the values of the parameters
9297
* of any containing callables, i.e. the context of the node.
9398
*/
94-
callableHasSideEffects(node: SdsCallable, substitutions = NO_SUBSTITUTIONS): boolean {
99+
callableHasSideEffects(node: SdsCallable | undefined, substitutions = NO_SUBSTITUTIONS): boolean {
95100
return this.getImpurityReasonsForCallable(node, substitutions).some((it) => it.isSideEffect);
96101
}
97102

@@ -105,10 +110,37 @@ export class SafeDsPurityComputer {
105110
* The parameter substitutions to use. These are **not** the argument of a call, but the values of the parameters
106111
* of any containing callables, i.e. the context of the node.
107112
*/
108-
expressionHasSideEffects(node: SdsExpression, substitutions = NO_SUBSTITUTIONS): boolean {
113+
expressionHasSideEffects(node: SdsExpression | undefined, substitutions = NO_SUBSTITUTIONS): boolean {
109114
return this.getImpurityReasonsForExpression(node, substitutions).some((it) => it.isSideEffect);
110115
}
111116

117+
/**
118+
* Returns whether the given statement does something. It must either
119+
* - create a placeholder,
120+
* - assign to a result, or
121+
* - call a function that has side effects.
122+
*
123+
* @param node
124+
* The statement to check.
125+
*
126+
* @param substitutions
127+
* The parameter substitutions to use. These are **not** the argument of a call, but the values of the parameters
128+
* of any containing callables, i.e. the context of the node.
129+
*/
130+
statementDoesSomething(node: SdsStatement, substitutions = NO_SUBSTITUTIONS): boolean {
131+
if (isSdsAssignment(node)) {
132+
return (
133+
!getAssignees(node).every(isSdsWildcard) ||
134+
this.expressionHasSideEffects(node.expression, substitutions)
135+
);
136+
} else if (isSdsExpressionStatement(node)) {
137+
return this.expressionHasSideEffects(node.expression, substitutions);
138+
} else {
139+
/* c8 ignore next 2 */
140+
return false;
141+
}
142+
}
143+
112144
/**
113145
* Returns the reasons why the given callable is impure.
114146
*
@@ -119,7 +151,7 @@ export class SafeDsPurityComputer {
119151
* The parameter substitutions to use. These are **not** the argument of a call, but the values of the parameters
120152
* of any containing callables, i.e. the context of the node.
121153
*/
122-
getImpurityReasonsForCallable(node: SdsCallable, substitutions = NO_SUBSTITUTIONS): ImpurityReason[] {
154+
getImpurityReasonsForCallable(node: SdsCallable | undefined, substitutions = NO_SUBSTITUTIONS): ImpurityReason[] {
123155
return this.getImpurityReasons(node, substitutions);
124156
}
125157

@@ -133,28 +165,54 @@ export class SafeDsPurityComputer {
133165
* The parameter substitutions to use. These are **not** the argument of a call, but the values of the parameters
134166
* of any containing callables, i.e. the context of the node.
135167
*/
136-
getImpurityReasonsForExpression(node: SdsExpression, substitutions = NO_SUBSTITUTIONS): ImpurityReason[] {
168+
getImpurityReasonsForExpression(
169+
node: SdsExpression | undefined,
170+
substitutions = NO_SUBSTITUTIONS,
171+
): ImpurityReason[] {
137172
return this.getExecutedCallsInExpression(node).flatMap((it) => this.getImpurityReasons(it, substitutions));
138173
}
139174

140-
private getImpurityReasons(node: SdsCall | SdsCallable, substitutions = NO_SUBSTITUTIONS): ImpurityReason[] {
141-
const key = this.getNodeId(node);
142-
return this.reasonsCache.get(key, () => {
143-
return this.callGraphComputer
144-
.getCallGraph(node, substitutions)
145-
.streamCalledCallables()
146-
.flatMap((it) => {
147-
if (isSdsFunction(it)) {
148-
return this.getImpurityReasonsForFunction(it);
149-
} else {
150-
return EMPTY_STREAM;
151-
}
152-
})
153-
.toArray();
175+
private getImpurityReasons(
176+
node: SdsCall | SdsCallable | undefined,
177+
substitutions = NO_SUBSTITUTIONS,
178+
): ImpurityReason[] {
179+
if (!node) {
180+
/* c8 ignore next 2 */
181+
return [];
182+
}
183+
184+
// Cache the result if no substitutions are given
185+
if (isEmpty(substitutions)) {
186+
const key = this.getNodeId(node);
187+
return this.reasonsCache.get(key, () => {
188+
return this.doGetImpurityReasons(node, substitutions);
189+
});
190+
} else {
191+
/* c8 ignore next 2 */
192+
return this.doGetImpurityReasons(node, substitutions);
193+
}
194+
}
195+
196+
private doGetImpurityReasons(node: SdsCall | SdsCallable, substitutions = NO_SUBSTITUTIONS): ImpurityReason[] {
197+
const callGraph = this.callGraphComputer.getCallGraph(node, substitutions);
198+
199+
const recursionImpurityReason: ImpurityReason[] = [];
200+
if (callGraph.isRecursive) {
201+
recursionImpurityReason.push(EndlessRecursion);
202+
}
203+
204+
const otherImpurityReasons = callGraph.streamCalledCallables().flatMap((it) => {
205+
if (isSdsFunction(it)) {
206+
return this.getImpurityReasonsForFunction(it);
207+
} else {
208+
return EMPTY_STREAM;
209+
}
154210
});
211+
212+
return [...recursionImpurityReason, ...otherImpurityReasons];
155213
}
156214

157-
private getExecutedCallsInExpression(expression: SdsExpression): SdsCall[] {
215+
private getExecutedCallsInExpression(expression: SdsExpression | undefined): SdsCall[] {
158216
return this.callGraphComputer.getAllContainedCalls(expression).filter((it) => {
159217
// Keep only calls that are not contained in a lambda inside the expression
160218
const containingLambda = getContainerOfType(it, isSdsLambda);
Lines changed: 3 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,18 @@
1-
import {
2-
isSdsAssignment,
3-
isSdsExpressionStatement,
4-
isSdsWildcard,
5-
SdsExpression,
6-
SdsStatement,
7-
} from '../../../generated/ast.js';
1+
import { SdsStatement } from '../../../generated/ast.js';
82
import { ValidationAcceptor } from 'langium';
93
import { SafeDsServices } from '../../../safe-ds-module.js';
10-
import { getAssignees } from '../../../helpers/nodeProperties.js';
114

125
export const CODE_STATEMENT_HAS_NO_EFFECT = 'statement/has-no-effect';
136

147
export const statementMustDoSomething = (services: SafeDsServices) => {
15-
const statementDoesSomething = statementDoesSomethingProvider(services);
8+
const purityComputer = services.purity.PurityComputer;
169

1710
return (node: SdsStatement, accept: ValidationAcceptor): void => {
18-
if (!statementDoesSomething(node)) {
11+
if (!purityComputer.statementDoesSomething(node)) {
1912
accept('warning', 'This statement does nothing.', {
2013
node,
2114
code: CODE_STATEMENT_HAS_NO_EFFECT,
2215
});
2316
}
2417
};
2518
};
26-
27-
const statementDoesSomethingProvider = (services: SafeDsServices) => {
28-
const statementExpressionDoesSomething = statementExpressionDoesSomethingProvider(services);
29-
30-
return (node: SdsStatement): boolean => {
31-
if (isSdsAssignment(node)) {
32-
return !getAssignees(node).every(isSdsWildcard) || statementExpressionDoesSomething(node.expression);
33-
} else if (isSdsExpressionStatement(node)) {
34-
return statementExpressionDoesSomething(node.expression);
35-
} else {
36-
/* c8 ignore next 2 */
37-
return false;
38-
}
39-
};
40-
};
41-
42-
const statementExpressionDoesSomethingProvider = (services: SafeDsServices) => {
43-
const callGraphComputer = services.flow.CallGraphComputer;
44-
const purityComputer = services.purity.PurityComputer;
45-
46-
return (node: SdsExpression | undefined): boolean => {
47-
if (!node) {
48-
/* c8 ignore next 2 */
49-
return false;
50-
}
51-
52-
return (
53-
callGraphComputer.getAllContainedCalls(node).some((it) => callGraphComputer.isRecursive(it)) ||
54-
!purityComputer.isPureExpression(node)
55-
);
56-
};
57-
};

packages/safe-ds-lang/tests/language/documentation/safe-ds-comment-provider.test.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { AssertionError } from 'assert';
22
import { AstNode, EmptyFileSystem } from 'langium';
3-
import { clearDocuments } from 'langium/test';
4-
import { afterEach, describe, expect, it } from 'vitest';
3+
import { describe, expect, it } from 'vitest';
54
import {
65
isSdsAnnotation,
76
isSdsAttribute,
@@ -21,10 +20,6 @@ const commentProvider = services.documentation.CommentProvider;
2120
const testComment = '/* test */';
2221

2322
describe('SafeDsCommentProvider', () => {
24-
afterEach(async () => {
25-
await clearDocuments(services);
26-
});
27-
2823
const testCases: CommentProviderTest[] = [
2924
{
3025
testName: 'commented module member (without annotations)',

packages/safe-ds-lang/tests/language/documentation/safe-ds-documentation-provider.test.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { AstNode, EmptyFileSystem, expandToString } from 'langium';
2-
import { clearDocuments } from 'langium/test';
3-
import { afterEach, describe, expect, it } from 'vitest';
2+
import { describe, expect, it } from 'vitest';
43
import { normalizeLineBreaks } from '../../../src/helpers/stringUtils.js';
54
import {
65
isSdsAnnotation,
@@ -17,10 +16,6 @@ const documentationProvider = services.documentation.DocumentationProvider;
1716
const testDocumentation = 'Lorem ipsum.';
1817

1918
describe('SafeDsDocumentationProvider', () => {
20-
afterEach(async () => {
21-
await clearDocuments(services);
22-
});
23-
2419
const testCases: DocumentationProviderTest[] = [
2520
{
2621
testName: 'module member',

packages/safe-ds-lang/tests/language/generation/safe-ds-python-generator.test.ts

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,15 @@
1-
import { createSafeDsServices } from '../../../src/language/index.js';
2-
import { clearDocuments } from 'langium/test';
3-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
1+
import { createSafeDsServicesWithBuiltins } from '../../../src/language/index.js';
2+
import { describe, expect, it } from 'vitest';
43
import { NodeFileSystem } from 'langium/node';
54
import { createGenerationTests } from './creator.js';
65
import { loadDocuments } from '../../helpers/testResources.js';
76
import { stream } from 'langium';
87

9-
const services = createSafeDsServices(NodeFileSystem).SafeDs;
8+
const services = (await createSafeDsServicesWithBuiltins(NodeFileSystem)).SafeDs;
109
const pythonGenerator = services.generation.PythonGenerator;
1110
const generationTests = createGenerationTests();
1211

1312
describe('generation', async () => {
14-
beforeEach(async () => {
15-
// Load the builtin library
16-
await services.shared.workspace.WorkspaceManager.initializeWorkspace([]);
17-
});
18-
19-
afterEach(async () => {
20-
await clearDocuments(services);
21-
});
22-
2313
it.each(await generationTests)('$testName', async (test) => {
2414
// Test is invalid
2515
if (test.error) {

packages/safe-ds-lang/tests/language/grammar/safe-ds-grammar.test.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,13 @@
1-
import { afterEach, describe, it } from 'vitest';
1+
import { describe, it } from 'vitest';
22
import { createSafeDsServices } from '../../../src/language/index.js';
33
import { AssertionError } from 'assert';
44
import { NodeFileSystem } from 'langium/node';
55
import { createGrammarTests } from './creator.js';
6-
import { clearDocuments } from 'langium/test';
76
import { getSyntaxErrors } from '../../helpers/diagnostics.js';
87

98
const services = createSafeDsServices(NodeFileSystem).SafeDs;
109

1110
describe('grammar', () => {
12-
afterEach(async () => {
13-
await clearDocuments(services);
14-
});
15-
1611
it.each(createGrammarTests())('$testName', async (test) => {
1712
// Test is invalid
1813
if (test.error) {

0 commit comments

Comments
 (0)