Skip to content
This repository has been archived by the owner on Mar 25, 2021. It is now read-only.

Commit

Permalink
Add rule: strict-string-expressions (#4807)
Browse files Browse the repository at this point in the history
* feat: add strict-string-expressions

* docs(strictStringExpressionsRule): add option desc

* feat(strictStringExpressionsRule): add fixer

* test: add strict-string-expressions into all.ts

* type: remove declare

* refactor(strictStringExpressionsRule): eliminate copy-paste

* style: fix tslint errors

* fix(strictStringExpressionsRule): exclude any type from checks

* style: fix sytle erorrs

* refactor: rename function name to more cohesive

* refactor(strictStringExpressionsRule): add more renaimings

* style(strictStringExpressionsRule): prettify

* fix: do not require numbers to be stringified

* test(strictStringExpressions): add string and  number literals

* test(strictStringExpressionsRule): fix nit

* test(strictStringExpressionsRule): add string uni in test

* test: add typeof window case

* feat: handle union type

* feat(strictStringExpressionsRule): add allow-empty-types option

* feat: allo empty types in all.ts config

* style(strictStringExpressionsRule): fix lint rules

* feat(strictStringExpressionsRule): allow booleans

* style(linter): fix codestyle

* refactor(strictStringExpressionsRule): rename private helpers

* docs(strictStringExpressionsRule): update license year

* refactor(strictStringExpressionsRule): make allow-empty-types to appear
default option

* test(stringStrictExpressions): correct test
  • Loading branch information
ColCh authored and Josh Goldberg committed Aug 12, 2019
1 parent f772a15 commit 05cfde7
Show file tree
Hide file tree
Showing 11 changed files with 752 additions and 1 deletion.
1 change: 1 addition & 0 deletions src/configs/all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export const rules = {
"restrict-plus-operands": true,
"static-this": true,
"strict-boolean-expressions": true,
"strict-string-expressions": true,
"strict-comparisons": true,
"strict-type-predicates": true,
"switch-default": true,
Expand Down
2 changes: 1 addition & 1 deletion src/linter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ export class Linter {
this.options.formatter !== undefined ? this.options.formatter : "prose";
const Formatter = findFormatter(formatterName, this.options.formattersDirectory);
if (Formatter === undefined) {
throw new Error(`formatter '${formatterName}' not found`);
throw new Error(`formatter '${String(formatterName)}' not found`);
}
const formatter = new Formatter();

Expand Down
159 changes: 159 additions & 0 deletions src/rules/strictStringExpressionsRule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* @license
* Copyright 2019 Palantir Technologies, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { isTypeFlagSet, isUnionType } from "tsutils";
import * as ts from "typescript";

import * as Lint from "../index";

const OPTION_ALLOW_EMPTY_TYPES = "allow-empty-types";

interface Options {
[OPTION_ALLOW_EMPTY_TYPES]?: boolean;
}

export class Rule extends Lint.Rules.TypedRule {
public static CONVERSION_REQUIRED = "Explicit conversion to string type required";

public static metadata: Lint.IRuleMetadata = {
description: "Disable implicit toString() calls",
descriptionDetails: Lint.Utils.dedent`
Require explicit toString() call for variables used in strings. By default only strings are allowed.
The following nodes are checked:
* String literals ("foo" + bar)
* ES6 templates (\`foo \${bar}\`)`,
hasFix: true,
optionExamples: [
true,
[
true,
{
[OPTION_ALLOW_EMPTY_TYPES]: true,
},
],
],
options: {
properties: {
[OPTION_ALLOW_EMPTY_TYPES]: {
type: "boolean",
},
},
type: "object",
},
optionsDescription: Lint.Utils.dedent`
Following arguments may be optionally provided:
* \`${OPTION_ALLOW_EMPTY_TYPES}\` allows \`null\`, \`undefined\` and \`never\` to be passed into strings without explicit conversion`,
requiresTypeInfo: true,
ruleName: "strict-string-expressions",
type: "functionality",
typescriptOnly: true,
};

public applyWithProgram(sourceFile: ts.SourceFile, program: ts.Program): Lint.RuleFailure[] {
return this.applyWithFunction(
sourceFile,
walk,
this.getRuleOptions(),
program.getTypeChecker(),
);
}

private getRuleOptions(): Options {
if (this.ruleArguments[0] === undefined) {
return {
[OPTION_ALLOW_EMPTY_TYPES]: true,
};
} else {
return this.ruleArguments[0] as Options;
}
}
}

function walk(ctx: Lint.WalkContext<Options>, checker: ts.TypeChecker): void {
const { sourceFile, options } = ctx;
ts.forEachChild(sourceFile, function cb(node: ts.Node): void {
switch (node.kind) {
case ts.SyntaxKind.BinaryExpression: {
const binaryExpr = node as ts.BinaryExpression;
if (binaryExpr.operatorToken.kind === ts.SyntaxKind.PlusToken) {
const leftIsPassedAsIs = typeCanBeStringifiedEasily(
checker.getTypeAtLocation(binaryExpr.left),
options,
);
const rightIsPassedAsIs = typeCanBeStringifiedEasily(
checker.getTypeAtLocation(binaryExpr.right),
options,
);
const leftIsFailed = !leftIsPassedAsIs && rightIsPassedAsIs;
const rightIsFailed = leftIsPassedAsIs && !rightIsPassedAsIs;
if (leftIsFailed || rightIsFailed) {
const expression = leftIsFailed ? binaryExpr.left : binaryExpr.right;
addFailure(binaryExpr, expression);
}
}
break;
}
case ts.SyntaxKind.TemplateSpan: {
const templateSpanNode = node as ts.TemplateSpan;
const type = checker.getTypeAtLocation(templateSpanNode.expression);
const shouldPassAsIs = typeCanBeStringifiedEasily(type, options);
if (!shouldPassAsIs) {
const { expression } = templateSpanNode;
addFailure(templateSpanNode, expression);
}
}
}
return ts.forEachChild(node, cb);
});

function addFailure(node: ts.Node, expression: ts.Expression) {
const fix = Lint.Replacement.replaceFromTo(
expression.getStart(),
expression.end,
`String(${expression.getText()})`,
);
ctx.addFailureAtNode(node, Rule.CONVERSION_REQUIRED, fix);
}
}

const typeIsEmpty = (type: ts.Type): boolean =>
isTypeFlagSet(type, ts.TypeFlags.Null) ||
isTypeFlagSet(type, ts.TypeFlags.VoidLike) ||
isTypeFlagSet(type, ts.TypeFlags.Undefined) ||
isTypeFlagSet(type, ts.TypeFlags.Never);

function typeCanBeStringifiedEasily(type: ts.Type, options: Options): boolean {
if (isUnionType(type)) {
return type.types.every(unionAtomicType =>
typeCanBeStringifiedEasily(unionAtomicType, options),
);
}

if (options[OPTION_ALLOW_EMPTY_TYPES] && typeIsEmpty(type)) {
return true;
}

return (
isTypeFlagSet(type, ts.TypeFlags.BooleanLike) ||
isTypeFlagSet(type, ts.TypeFlags.StringOrNumberLiteral) ||
isTypeFlagSet(type, ts.TypeFlags.NumberLike) ||
isTypeFlagSet(type, ts.TypeFlags.StringLike) ||
isTypeFlagSet(type, ts.TypeFlags.Any)
);
}
133 changes: 133 additions & 0 deletions test/rules/strict-string-expressions/allow-empty-types/test.ts.fix
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
const fooAny: any;
const fooStr: string = 'foo';
const fooNumber = 2;
class FooClass {}
class ClassWithToString {
public static toString () { return ''; }
public toString () { return ''; }
}
const classWithToString = new ClassWithToString();
const FooStr = new String('foo');
const fooArr = ['foo'];
const emptyArr = [];
const stringUni = "foo" | "bar";
const booleanVar: boolean;

`foo`
`${'str literal'}`
`${123}`
`${booleanVar}`
`${fooAny}`
`${fooStr}`
`${stringUni}`
`${fooNumber}`
`${(typeof window)}`
`${String(FooClass)}`
`${String(ClassWithToString)}`
`${String(classWithToString)}`
`${String(FooStr)}`
`${String(fooArr)}`
`${String(emptyArr)}`

`${String('str literal')}`
`${String(123)}`
`${String(booleanVar)}`
`${String(fooAny)}`
`${String(fooStr)}`
`${String(stringUni)}`
`${String(fooNumber)}`
`${String((typeof window))}`
`${String(FooClass)}`
`${String(ClassWithToString)}`
`${String(classWithToString)}`
`${String(FooStr)}`
`${String(fooArr)}`
`${String(emptyArr)}`

`${'str literal'.toString()}`
`${123..toString()}`
`${booleanVar.toString()}`
`${fooAny.toString()}`
`${fooStr.toString()}`
`${stringUni.toString()}`
`${fooNumber.toString()}`
`${(typeof window).toString()}`
`${FooClass.toString()}`
`${ClassWithToString.toString()}`
`${classWithToString.toString()}`
`${FooStr.toString()}`
`${fooArr.toString()}`
`${emptyArr.toString()}`

'str' + 'str literal' + 'str'
'str' + 123 + 'str'
'str' + booleanVar + 'str'
'str' + fooAny + 'str'
'str' + fooStr + 'str'
'str' + stringUni + 'str'
'str' + fooNumber + 'str'
'str' + (typeof window) + 'str'
'str' + String(FooClass) + 'str'
'str' + String(ClassWithToString) + 'str'
'str' + String(classWithToString) + 'str'
'str' + String(FooStr) + 'str'
'str' + String(fooArr) + 'str'
'str' + String(emptyArr) + 'str'

'str' + String('str literal') + 'str'
'str' + String(123) + 'str'
'str' + String(booleanVar) + 'str'
'str' + String(fooAny) + 'str'
'str' + String(fooStr) + 'str'
'str' + String(stringUni) + 'str'
'str' + String(fooNumber) + 'str'
'str' + String((typeof window)) + 'str'
'str' + String(FooClass) + 'str'
'str' + String(ClassWithToString) + 'str'
'str' + String(classWithToString) + 'str'
'str' + String(FooStr) + 'str'
'str' + String(fooArr) + 'str'
'str' + String(emptyArr) + 'str'

'str' + 'str literal'.toString() + 'str'
'str' + 123..toString() + 'str'
'str' + booleanVar.toString() + 'str'
'str' + fooAny.toString() + 'str'
'str' + fooStr.toString() + 'str'
'str' + stringUni.toString() + 'str'
'str' + fooNumber.toString() + 'str'
'str' + (typeof window).toString() + 'str'
'str' + FooClass.toString() + 'str'
'str' + ClassWithToString.toString() + 'str'
'str' + classWithToString.toString() + 'str'
'str' + FooStr.toString() + 'str'
'str' + fooArr.toString() + 'str'
'str' + emptyArr.toString() + 'str'

const barFooStrOrUndef: string | undefined;
const barFooStrOrNull: string | null;
const neverType: never;

`${barFooStrOrUndef}`
`${barFooStrOrNull}`
`${neverType}`

`${String(barFooStrOrUndef)}`
`${String(barFooStrOrNull)}`
`${String(neverType)}`

`${barFooStrOrUndef.toString()}`
`${barFooStrOrNull.toString()}`
`${neverType.toString()}`

'str' + barFooStrOrUndef + 'str'
'str' + barFooStrOrNull + 'str'
'str' + neverType + 'str'

'str' + String(barFooStrOrUndef) + 'str'
'str' + String(barFooStrOrNull) + 'str'
'str' + String(neverType) + 'str'

'str' + barFooStrOrUndef.toString() + 'str'
'str' + barFooStrOrNull.toString() + 'str'
'str' + neverType.toString() + 'str'
Loading

0 comments on commit 05cfde7

Please sign in to comment.