diff --git a/src/parsers/angular.ts b/src/parsers/angular.ts index f7cafbc..7f9eb01 100644 --- a/src/parsers/angular.ts +++ b/src/parsers/angular.ts @@ -284,10 +284,15 @@ function createLiteralsByAngularBoundAttributeName(ctx: Rule.RuleContext, attrib const line = ctx.sourceCode.lines[loc.start.line - 1]; const indentation = getIndentation(line); const supportsMultiline = false; + const concatenation = { + isConcatenatedLeft: false, + isConcatenatedRight: false + }; return [{ ...quotes, ...whitespaces, + ...concatenation, content, indentation, loc, @@ -332,10 +337,15 @@ function createLiteralByLiteralMapKey(ctx: Rule.RuleContext, key: LiteralMapProp const loc = getLocByRange(ctx, range); const line = ctx.sourceCode.lines[loc.start.line - 1] ?? ""; const indentation = getIndentation(line); + const concatenation = { + isConcatenatedLeft: false, + isConcatenatedRight: false + }; return [{ ...quotes, ...whitespaces, + ...concatenation, content: keyContent, indentation, loc, @@ -363,10 +373,15 @@ function createLiteralsByAngularTextAttribute(ctx: Rule.RuleContext, attribute: const line = ctx.sourceCode.lines[loc.start.line - 1]; const indentation = getIndentation(line); const supportsMultiline = true; + const concatenation = { + isConcatenatedLeft: false, + isConcatenatedRight: false + }; return [{ ...quotes, ...whitespaces, + ...concatenation, content, indentation, loc, @@ -393,11 +408,13 @@ function createLiteralByAngularLiteralPrimitive(ctx: Rule.RuleContext, literal: const loc = getLocByRange(ctx, range); const line = ctx.sourceCode.lines[loc.start.line - 1]; const indentation = getIndentation(line); + const concatenation = getStringConcatenationMeta(ctx, literal); const supportsMultiline = true; return [{ ...quotes, ...whitespaces, + ...concatenation, content, indentation, loc, @@ -433,11 +450,13 @@ function createLiteralByAngularTemplateLiteralElement(ctx: Rule.RuleContext, lit const parentLine = ctx.sourceCode.lines[parentLoc.start.line - 1]; const indentation = getIndentation(parentLine); const supportsMultiline = true; + const concatenation = getStringConcatenationMeta(ctx, literal); return [{ ...quotes, ...whitespaces, ...braces, + ...concatenation, content, indentation, isInterpolated, @@ -534,6 +553,27 @@ function isInsideLogicalExpressionLeft(ctx: Rule.RuleContext, ast: AST): boolean return isInsideConditionalExpressionCondition(ctx, parent); } +function getStringConcatenationMeta(ctx: Rule.RuleContext, ast: AST, isConcatenatedLeft = false, isConcatenatedRight = false): { isConcatenatedLeft: boolean; isConcatenatedRight: boolean; } { + const parent = findParent(ctx, ast); + if(!parent){ + return { + isConcatenatedLeft, + isConcatenatedRight + }; + } + + if(isBinary(parent) && parent.operation === "+"){ + return getStringConcatenationMeta( + ctx, + parent, + isConcatenatedLeft || parent.right === ast, + isConcatenatedRight || parent.left === ast + ); + } + + return getStringConcatenationMeta(ctx, parent, isConcatenatedLeft, isConcatenatedRight); +} + function isInsideObjectValue(ctx: Rule.RuleContext, ast: AST): boolean { const parent = findParent(ctx, ast); if(!parent){ return false; } diff --git a/src/parsers/es.test.ts b/src/parsers/es.test.ts index 703bdda..f06d16d 100644 --- a/src/parsers/es.test.ts +++ b/src/parsers/es.test.ts @@ -235,7 +235,7 @@ describe("es", () => { }); // #234 - it("should ignore literals in binary expressions", () => { + it("should ignore literals in binary comparisons", () => { lint(noUnnecessaryWhitespace, { valid: [ { diff --git a/src/parsers/es.ts b/src/parsers/es.ts index e4d0bc9..3546488 100644 --- a/src/parsers/es.ts +++ b/src/parsers/es.ts @@ -3,8 +3,8 @@ import { findMatchingParentNodes, getLiteralNodesByMatchers, isIndexedAccessLiteral, - isInsideBinaryExpression, isInsideConditionalExpressionTest, + isInsideDisallowedBinaryExpression, isInsideLogicalExpressionLeft, isInsideMemberExpression, matchesPathPattern @@ -197,11 +197,13 @@ export function getStringLiteralByESStringLiteral(ctx: Rule.RuleContext, node: E const indentation = getIndentation(line); const multilineQuotes = getMultilineQuotes(node); const supportsMultiline = !isESObjectKey(node); + const concatenation = getStringConcatenationMeta(node); return { ...quotes, ...whitespaces, ...multilineQuotes, + ...concatenation, content, indentation, isInterpolated: false, @@ -233,12 +235,14 @@ function getLiteralByESTemplateElement(ctx: Rule.RuleContext, node: ESTemplateEl const whitespaces = getWhitespace(content); const indentation = getIndentation(line); const multilineQuotes = getMultilineQuotes(node); + const concatenation = getStringConcatenationMeta(node); return { ...whitespaces, ...quotes, ...braces, ...multilineQuotes, + ...concatenation, content, indentation, isInterpolated, @@ -609,6 +613,27 @@ function getIsInterpolated(ctx: Rule.RuleContext, raw: string): boolean { return !!braces.closingBraces || !!braces.openingBraces; } +function getStringConcatenationMeta(node: ESNode, isConcatenatedLeft = false, isConcatenatedRight = false): { isConcatenatedLeft: boolean; isConcatenatedRight: boolean; } { + if(!hasESNodeParentExtension(node)){ + return { + isConcatenatedLeft, + isConcatenatedRight + }; + } + + const parent = node.parent; + + if(parent.type === "BinaryExpression" && parent.operator === "+"){ + return getStringConcatenationMeta( + parent, + isConcatenatedLeft || parent.right === node, + isConcatenatedRight || parent.left === node + ); + } + + return getStringConcatenationMeta(parent, isConcatenatedLeft, isConcatenatedRight); +} + function getESMatcherFunctions(matchers: SelectorMatcher[]): MatcherFunctions { return matchers.reduce>((matcherFunctions, matcher) => { switch (matcher.type){ @@ -619,7 +644,7 @@ function getESMatcherFunctions(matchers: SelectorMatcher[]): MatcherFunctions { }); }); + it("should trim unnecessary whitespace in concatenated strings", () => { + lint(noUnnecessaryWhitespace, { + invalid: [ + { + angular: ``, + angularOutput: ``, + jsx: `() => `, + jsxOutput: `() => `, + svelte: ``, + svelteOutput: ``, + vue: ``, + vueOutput: ``, + + errors: 4 + }, + { + angular: ``, + angularOutput: ``, + jsx: `() => `, + jsxOutput: `() => `, + svelte: ``, + svelteOutput: ``, + vue: ``, + vueOutput: ``, + + errors: 6 + } + ], + valid: [ + { + angular: ``, + jsx: `() => `, + svelte: ``, + vue: `` + }, + { + angular: ``, + jsx: `() => `, + svelte: ``, + vue: `` + } + ] + }); + }); + + it("should trim unnecessary whitespace in conditionally concatenated strings", () => { + lint(noUnnecessaryWhitespace, { + invalid: [ + { + angular: ``, + angularOutput: ``, + jsx: `() => `, + jsxOutput: `() => `, + svelte: ``, + svelteOutput: ``, + vue: ``, + vueOutput: ``, + + errors: 6 + } + ], + valid: [ + { + angular: ``, + jsx: `() => `, + svelte: ``, + vue: `` + } + ] + }); + }); + + it("should trim unnecessary whitespace in conditionally concatenated template literal strings", () => { + lint(noUnnecessaryWhitespace, { + invalid: [ + { + angular: '', + angularOutput: '', + jsx: "() => ", + jsxOutput: "() => ", + svelte: "", + svelteOutput: "", + vue: '', + vueOutput: '', + + errors: 6 + } + ], + valid: [ + { + angular: '', + jsx: "() => ", + svelte: "", + vue: '' + } + ] + }); + }); + it("should remove whitespace in empty strings", () => { lint(noUnnecessaryWhitespace, { invalid: [ diff --git a/src/rules/no-unnecessary-whitespace.ts b/src/rules/no-unnecessary-whitespace.ts index bad0727..4223700 100644 --- a/src/rules/no-unnecessary-whitespace.ts +++ b/src/rules/no-unnecessary-whitespace.ts @@ -56,9 +56,27 @@ function lintLiterals(ctx: Context, literals: Li stringIndex += className.length; const [literalStart] = literal.range; + const keepLeadingWhitespace = literal.isConcatenatedLeft === true; + const keepTrailingWhitespace = literal.isConcatenatedRight === true; // whitespaces only if(classChunks.length === 0 && !literal.closingBraces && !literal.openingBraces){ + if(keepLeadingWhitespace || keepTrailingWhitespace){ + if(whitespace.length <= 1){ + continue; + } + + ctx.report({ + fix: " ", + id: "unnecessary", + range: [ + literalStart + startIndex, + literalStart + endIndex + ] + }); + continue; + } + if(whitespace === ""){ continue; } @@ -121,6 +139,27 @@ function lintLiterals(ctx: Context, literals: Li // leading or trailing whitespace if(isFirstChunk || isLastChunk){ + const keepCurrentWhitespace = + isFirstChunk && keepLeadingWhitespace || + isLastChunk && keepTrailingWhitespace; + + if(keepCurrentWhitespace){ + if(whitespace.length <= 1){ + continue; + } + + ctx.report({ + fix: " ", + id: "unnecessary", + range: [ + literalStart + startIndex, + literalStart + endIndex + ] + }); + + continue; + } + if(whitespace === ""){ continue; } diff --git a/src/types/ast.ts b/src/types/ast.ts index 71773d6..29bac45 100644 --- a/src/types/ast.ts +++ b/src/types/ast.ts @@ -55,6 +55,8 @@ interface LiteralBase extends NodeBase, MultilineMeta, QuoteMeta, BracesMeta, Wh content: string; raw: string; attribute?: string | undefined; + isConcatenatedLeft?: boolean | undefined; + isConcatenatedRight?: boolean | undefined; isInterpolated?: boolean | undefined; priorLiterals?: Literal[] | undefined; } diff --git a/src/utils/matchers.ts b/src/utils/matchers.ts index d1f364d..fc96ffd 100644 --- a/src/utils/matchers.ts +++ b/src/utils/matchers.ts @@ -119,10 +119,13 @@ export function isInsideConditionalExpressionTest(node: WithParent): boo return isInsideConditionalExpressionTest(node.parent); } -export function isInsideBinaryExpression(node: WithParent): boolean { +export function isInsideDisallowedBinaryExpression(node: WithParent): boolean { if(!hasESNodeParentExtension(node)){ return false; } - if(node.parent.type === "BinaryExpression"){ return true; } - return isInsideBinaryExpression(node.parent); + if( + node.parent.type === "BinaryExpression" && + node.parent.operator !== "+" // allow string concatenation + ){ return true; } + return isInsideDisallowedBinaryExpression(node.parent); } export function isInsideLogicalExpressionLeft(node: WithParent): boolean {