Skip to content

Commit c490da2

Browse files
committed
Add REPL lint rule for return annotation values
1 parent 270c83b commit c490da2

File tree

4 files changed

+385
-1
lines changed

4 files changed

+385
-1
lines changed

.rtlintrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"@stdlib/_tools/repl-txt/rules/code-return-values": "error",
2+
"@stdlib/_tools/repl-txt/rules/return-annotation-values": "error",
33
"@stdlib/_tools/repl-txt/rules/code-semicolons": "error",
44
"@stdlib/_tools/repl-txt/rules/has-parameters-section": "error",
55
"@stdlib/_tools/repl-txt/rules/has-alias-signature": "error",
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* @license Apache-2.0
3+
*
4+
* Copyright (c) 2018 The Stdlib Authors.
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
'use strict';
20+
21+
// MODULES //
22+
23+
var roundn = require( '@stdlib/math/base/special/roundn' );
24+
var epsdiff = require( '@stdlib/math/base/utils/float64-epsilon-difference' );
25+
var indexOf = require( '@stdlib/utils/index-of' );
26+
var isNull = require( '@stdlib/assert/is-null' );
27+
var isNumber = require( '@stdlib/assert/is-number' ).isPrimitive;
28+
var isBoolean = require( '@stdlib/assert/is-boolean' ).isPrimitive;
29+
var isString = require( '@stdlib/assert/is-string' ).isPrimitive;
30+
var startsWith = require( '@stdlib/string/starts-with' );
31+
var contains = require( '@stdlib/assert/contains' );
32+
var endsWith = require( '@stdlib/string/ends-with' );
33+
var removeFirst = require( '@stdlib/string/remove-first' );
34+
var removeLast = require( '@stdlib/string/remove-last' );
35+
var replace = require( '@stdlib/string/replace' );
36+
var trim = require( '@stdlib/string/trim' );
37+
38+
39+
// MAIN //
40+
41+
/**
42+
* Checks whether the actual return value is equal to the value of the return annotation.
43+
*
44+
* @private
45+
* @param {*} actual - actual return value
46+
* @param {string} expected - return value annotation
47+
* @returns {(string|null)} error message in case of mismatch, `null` otherwise
48+
*/
49+
function compareValues( actual, expected ) {
50+
var parts;
51+
var dgts;
52+
var msg1;
53+
var msg2;
54+
var a;
55+
var b;
56+
if ( contains( expected, '||' ) ) {
57+
parts = expected.split( '||' );
58+
a = trim( parts[ 0 ] );
59+
b = trim( parts[ 1 ] );
60+
msg1 = compareValues( actual, a );
61+
msg2 = compareValues( actual, b );
62+
if ( msg1 && msg2 ) {
63+
return 'Displayed return value should be '+a+' or '+b+', but actual value is `'+actual+'`';
64+
}
65+
return null;
66+
}
67+
if ( expected === 'NaN' ) {
68+
if ( !isNaN( actual ) ) {
69+
return 'Displayed return value is `NaN`, but function returns `'+actual+'` instead';
70+
}
71+
}
72+
else if ( expected === 'null' ) {
73+
if ( !isNull( actual ) ) {
74+
return 'Displayed return value is `null`, but function returns `'+actual+'` instead';
75+
}
76+
}
77+
else if ( isNumber( actual ) ) {
78+
if ( startsWith( expected, '~' ) ) {
79+
if ( contains( expected, 'e' ) ) {
80+
dgts = indexOf( expected, 'e' ) - indexOf( expected, '.' );
81+
a = actual.toPrecision( dgts );
82+
b = removeFirst( expected );
83+
} else {
84+
dgts = expected.length - indexOf( expected, '.' ) - 1;
85+
a = roundn( actual, -dgts );
86+
b = roundn( parseFloat( removeFirst( expected ) ), -dgts );
87+
}
88+
} else {
89+
a = actual;
90+
b = parseFloat( expected );
91+
}
92+
if ( epsdiff( a, b ) > 10.0 ) {
93+
return 'Displayed return value is `'+expected+'`, but function returns `'+actual+'` instead';
94+
}
95+
}
96+
else if ( isBoolean( actual ) ) {
97+
actual = String( actual );
98+
if ( expected !== actual ) {
99+
return 'Displayed return value is `'+expected+'`, but function returns `'+actual+'` instead';
100+
}
101+
}
102+
else if ( isString( actual ) ) {
103+
if ( !startsWith( expected, '\'' ) || !endsWith( expected, '\'' ) ) {
104+
return '`'+expected+'` should be wrapped in single quotes';
105+
}
106+
expected = removeFirst( removeLast( expected ) );
107+
108+
// Harmonize escapes between annotations and actual values:
109+
expected = replace( expected, '\\\'', '\'' );
110+
actual = replace( actual, '\n', '\\n' );
111+
actual = replace( actual, '\t', '\\t' );
112+
if ( expected !== actual && expected !== '...' ) {
113+
return 'Displayed return value is `'+expected+'`, but function returns `'+actual+'` instead';
114+
}
115+
}
116+
}
117+
118+
119+
// EXPORTS //
120+
121+
module.exports = compareValues;
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
/**
2+
* @license Apache-2.0
3+
*
4+
* Copyright (c) 2018 The Stdlib Authors.
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
'use strict';
20+
21+
// MODULES //
22+
23+
var vm = require( 'vm' );
24+
var dirname = require( 'path' ).dirname;
25+
var replace = require( '@stdlib/string/replace' );
26+
var isNumber = require( '@stdlib/assert/is-number' ).isPrimitive;
27+
var isBoolean = require( '@stdlib/assert/is-boolean' ).isPrimitive;
28+
var isString = require( '@stdlib/assert/is-string' ).isPrimitive;
29+
var namespace = require( '@stdlib/namespace' );
30+
var compareValues = require( './compare_values.js' );
31+
32+
33+
// VARIABLES //
34+
35+
var RE_OUTSIDE_ALIAS = /{{alias:([\s\S]+?)}}/g;
36+
var RE_ASSIGNMENT = /(?:var|let|const)? ?([a-zA-Z0-9]*) ?=/;
37+
var RE_DOT = /\.([a-z])/gi;
38+
var NAMESPACE = namespace();
39+
40+
41+
// FUNCTIONS //
42+
43+
/**
44+
* Returns an alias for a given package name.
45+
*
46+
* @private
47+
* @param {string} pkg - package name
48+
* @returns {(string|null)} alias name or null
49+
*/
50+
function pkg2alias( pkg ) {
51+
var alias;
52+
var i;
53+
54+
for ( i = 0; i < NAMESPACE.length; i++ ) {
55+
if ( pkg === NAMESPACE[ i ].path ) {
56+
alias = NAMESPACE[ i ].alias;
57+
alias = replace( alias, RE_DOT, '$1' );
58+
return alias;
59+
}
60+
}
61+
return null;
62+
}
63+
64+
65+
// MAIN //
66+
67+
/**
68+
* Rule for enforcing that return annotation values mirror actual output.
69+
*
70+
* @param {Context} context - lint context
71+
* @returns {Object} validators
72+
*/
73+
function main( context ) {
74+
/**
75+
* Checks whether return annotation values mirror actual output.
76+
*
77+
* @private
78+
* @param {Object} section - examples section
79+
*/
80+
function returnValues( section ) {
81+
var examples;
82+
var expected;
83+
var current;
84+
var pkgName;
85+
var actual;
86+
var match;
87+
var scope;
88+
var code;
89+
var msg;
90+
var pkg;
91+
var out;
92+
var a;
93+
var b;
94+
var i;
95+
96+
if ( context.meta.filepath ) {
97+
expected = [];
98+
actual = [];
99+
pkg = dirname( dirname( context.meta.filepath ) );
100+
101+
scope = {
102+
'setTimeout': setTimeout,
103+
'require': require
104+
};
105+
pkgName = pkg2alias( pkg ) || 'ALIAS';
106+
scope[ pkgName ] = require( pkg ); // eslint-disable-line stdlib/no-dynamic-require
107+
vm.createContext( scope );
108+
109+
examples = section.examples;
110+
for ( i = 0; i < examples.length; i++ ) {
111+
current = examples[ i ];
112+
code = current.code;
113+
code = replace( code, '{{alias}}', pkgName );
114+
code = replace( code, RE_OUTSIDE_ALIAS, replaceAliases );
115+
try {
116+
out = vm.runInContext( code, scope );
117+
if ( current.output ) {
118+
expected.push( current.output );
119+
match = code.match( RE_ASSIGNMENT );
120+
if ( match && match[ 1 ] ) {
121+
actual.push( scope[ match[ 1 ] ] );
122+
} else {
123+
actual.push( out );
124+
}
125+
}
126+
} catch ( err ) {
127+
if ( current.output !== '<Error>' ) {
128+
context.report( 'Received an unexpected error when running example `'+code+'`: ' + err.message, section );
129+
}
130+
}
131+
}
132+
for ( i = 0; i < expected.length; i++ ) {
133+
a = actual[ i ];
134+
b = expected[ i ];
135+
if ( !checkForPlaceholders( a, b ) ) {
136+
msg = compareValues( a, b );
137+
if ( msg ) {
138+
context.report( msg, section );
139+
}
140+
}
141+
}
142+
}
143+
144+
/**
145+
* Checks whether expected values are type placeholders and if so, whether the actual return values are of the respective type.
146+
*
147+
* @private
148+
* @param {*} actual - actual return value
149+
* @param {string} expected - return value annotation
150+
* @returns {boolean} boolean indicating whether annotation is a placeholder and the actual return type matches
151+
*/
152+
function checkForPlaceholders( actual, expected ) {
153+
if ( expected === '<boolean>' ) {
154+
if ( !isBoolean( actual ) ) {
155+
context.report( 'Expected a boolean, but received: `'+actual+'`', section );
156+
}
157+
return true;
158+
}
159+
if ( expected === '<string>' ) {
160+
if ( !isString( actual ) ) {
161+
context.report( 'Expected a string, but received: `'+actual+'`', section );
162+
}
163+
return true;
164+
}
165+
if ( expected === '<number>' ) {
166+
if ( !isNumber( actual ) ) {
167+
context.report( 'Expected a number, but received: `'+actual+'`', section );
168+
}
169+
return true;
170+
}
171+
return false;
172+
}
173+
174+
/**
175+
* Replaces aliases to other packages in REPL example code, while registering the respective packages in the `scope` of the generated function.
176+
*
177+
* @private
178+
* @param {string} match - full match
179+
* @param {string} pkg - package path
180+
* @returns {string} variable name
181+
*/
182+
function replaceAliases( match, pkg ) {
183+
var alias = pkg2alias( pkg );
184+
scope[ alias ] = require( pkg ); // eslint-disable-line stdlib/no-dynamic-require
185+
return alias;
186+
}
187+
}
188+
189+
return {
190+
'examples': returnValues
191+
};
192+
}
193+
194+
195+
// EXPORTS //
196+
197+
module.exports = main;
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
{
2+
"name": "@stdlib/_tools/repl-txt/rules/return-annotation-values",
3+
"version": "0.0.0",
4+
"description": "Lint rule to enforce that return annotation values mirror actual output.",
5+
"license": "Apache-2.0",
6+
"author": {
7+
"name": "The Stdlib Authors",
8+
"url": "https://github.com/stdlib-js/stdlib/graphs/contributors"
9+
},
10+
"contributors": [
11+
{
12+
"name": "The Stdlib Authors",
13+
"url": "https://github.com/stdlib-js/stdlib/graphs/contributors"
14+
}
15+
],
16+
"bin": {},
17+
"main": "./lib",
18+
"directories": {
19+
"lib": "./lib"
20+
},
21+
"scripts": {},
22+
"homepage": "https://github.com/stdlib-js/stdlib",
23+
"repository": {
24+
"type": "git",
25+
"url": "git://github.com/stdlib-js/stdlib.git"
26+
},
27+
"bugs": {
28+
"url": "https://github.com/stdlib-js/stdlib/issues"
29+
},
30+
"dependencies": {},
31+
"devDependencies": {},
32+
"engines": {
33+
"node": ">=0.10.0",
34+
"npm": ">2.7.0"
35+
},
36+
"os": [
37+
"aix",
38+
"darwin",
39+
"freebsd",
40+
"linux",
41+
"macos",
42+
"openbsd",
43+
"sunos",
44+
"win32",
45+
"windows"
46+
],
47+
"keywords": [
48+
"stdlib",
49+
"tools",
50+
"tool",
51+
"repl.txt",
52+
"repl",
53+
"lint",
54+
"custom",
55+
"rules",
56+
"rule",
57+
"plugin",
58+
"code",
59+
"example",
60+
"output",
61+
"annotation",
62+
"return",
63+
"value",
64+
"values"
65+
]
66+
}

0 commit comments

Comments
 (0)