Skip to content

Commit

Permalink
feat: add support for as const assertions, string literal union typ…
Browse files Browse the repository at this point in the history
…es, and quoted object keys (#96)
  • Loading branch information
swernerx authored Dec 23, 2024
1 parent b6f482d commit 04b198e
Show file tree
Hide file tree
Showing 2 changed files with 233 additions and 4 deletions.
91 changes: 90 additions & 1 deletion src/rules/no-unlocalized-strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from '../helpers'
import { createRule } from '../create-rule'
import * as micromatch from 'micromatch'
import { TypeFlags, UnionType, Type, Expression } from 'typescript'

type MatcherDef = string | { regex: { pattern: string; flags?: string } }

Expand Down Expand Up @@ -118,6 +119,70 @@ function isAssignedToIgnoredVariable(
return false
}

function isAsConstAssertion(node: TSESTree.Node): boolean {
const parent = node.parent
if (parent?.type === TSESTree.AST_NODE_TYPES.TSAsExpression) {
const typeAnnotation = parent.typeAnnotation
return (
typeAnnotation.type === TSESTree.AST_NODE_TYPES.TSTypeReference &&
isIdentifier(typeAnnotation.typeName) &&
typeAnnotation.typeName.name === 'const'
)
}
return false
}

function isStringLiteralFromUnionType(
node: TSESTree.Node,
tsService: ParserServicesWithTypeInformation,
): boolean {
try {
const checker = tsService.program.getTypeChecker()
const nodeTsNode = tsService.esTreeNodeToTSNodeMap.get(node)

const isStringLiteralType = (type: Type): boolean => {
if (type.flags & TypeFlags.Union) {
const unionType = type as UnionType
return unionType.types.every((t) => t.flags & TypeFlags.StringLiteral)
}
return !!(type.flags & TypeFlags.StringLiteral)
}

// For arguments, check parameter type first
if (node.parent?.type === TSESTree.AST_NODE_TYPES.CallExpression) {
const callNode = node.parent
const tsCallNode = tsService.esTreeNodeToTSNodeMap.get(callNode)

const args = callNode.arguments as TSESTree.CallExpressionArgument[]
const argIndex = args.findIndex((arg) => arg === node)

const signature = checker.getResolvedSignature(tsCallNode)
// Only proceed if we have a valid signature and the argument index is valid
if (signature?.parameters && argIndex >= 0 && argIndex < signature.parameters.length) {
const param = signature.parameters[argIndex]
const paramType = checker.getTypeAtLocation(param.valueDeclaration)

// For function parameters, we ONLY accept union types of string literals
if (paramType.flags & TypeFlags.Union) {
const unionType = paramType as UnionType
return unionType.types.every((t) => t.flags & TypeFlags.StringLiteral)
}
}
// If we're here, it's a function call argument that didn't match our criteria
return false
}

// Try to get the contextual type first
const contextualType = checker.getContextualType(nodeTsNode as Expression)
if (contextualType && isStringLiteralType(contextualType)) {
return true
}
} catch (error) {}

/* istanbul ignore next */
return false
}

export const name = 'no-unlocalized-strings'
export const rule = createRule<Option[], string>({
name,
Expand Down Expand Up @@ -565,6 +630,27 @@ export const rule = createRule<Option[], string>({
return
}

if (isAsConstAssertion(node)) {
return
}

// Add check for object property key
const parent = node.parent
if (parent?.type === TSESTree.AST_NODE_TYPES.Property && parent.key === node) {
return
}

// More thorough type checking when enabled
if (option?.useTsTypes && tsService) {
try {
if (isStringLiteralFromUnionType(node, tsService)) {
return
}
} catch (error) {
// Ignore type checking errors
}
}

if (isAssignedToIgnoredVariable(node, isIgnoredName)) {
return
}
Expand All @@ -573,7 +659,6 @@ export const rule = createRule<Option[], string>({
return
}

// Only ignore type context for property keys
if (isInsideTypeContext(node)) {
return
}
Expand All @@ -587,6 +672,10 @@ export const rule = createRule<Option[], string>({

if (!text || isTextWhiteListed(text)) return

if (isAsConstAssertion(node)) {
return
}

if (isAssignedToIgnoredVariable(node, isIgnoredName)) {
return // Do not report this template literal
}
Expand Down
146 changes: 143 additions & 3 deletions tests/src/rules/no-unlocalized-strings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,23 @@ ruleTester.run(name, rule, {
},
{
name: 'ignores special character strings',
code: 'const a = `0123456789!@#$%^&*()_+|~-=\\`[]{};\':",./<>?`;',
code: 'const special = `0123456789!@#$%^&*()_+|~-=\\`[]{};\':",./<>?`;',
},
{
name: 'accepts TSAsExpression assignment',
code: 'const unique = "this-is-unique" as const;',
},
{
name: 'accepts TSAsExpression assignment template literal',
code: 'const unique = `this-is-unique` as const;',
},
{
name: 'accepts TSAsExpression in array',
code: 'const names = ["name" as const, "city" as const];',
},
{
name: 'accepts TSAsExpression in object',
code: 'const paramsByDropSide = { top: "above" as const, bottom: "below" as const };',
},

// ==================== Template Literals with Variables ====================
Expand Down Expand Up @@ -114,10 +130,66 @@ ruleTester.run(name, rule, {
name: 'allows computed member expression with string literal',
code: 'obj["key with spaces"] = value',
},
{
name: 'allows declaring object keys in quotes',
code: 'const styles = { ":hover" : { color: theme.brand } }',
},
{
name: 'allows computed member expression with template literal',
code: 'obj[`key with spaces`] = value',
},
{
name: 'allow union types with string literals',
code: 'type Action = "add" | "remove"; function doAction(action: Action) {} doAction("add");',
options: [{ useTsTypes: true }],
},
{
name: 'allow inline union types with string literals',
code: 'function doAction(action: "add" | "remove") {} doAction("add");',
options: [{ useTsTypes: true }],
},
{
name: 'allow union types with optional string literals',
code: 'type Action = "add" | "remove" | undefined; function doAction(action: Action) {} doAction("add");',
options: [{ useTsTypes: true }],
},
{
name: 'allows direct union type variable assignment',
code: 'let value: "a" | "b"; value = "a";',
options: [{ useTsTypes: true }],
},
{
name: 'allows direct union type in object',
code: 'type Options = { mode: "light" | "dark" }; const options: Options = { mode: "light" };',
options: [{ useTsTypes: true }],
},
{
name: 'allows string literal in function parameter with union type',
code: `
function test(param: "a" | "b") {}
test("a");
`,
options: [{ useTsTypes: true }],
},
{
name: 'allows string literal in method parameter with union type',
code: `
class Test {
method(param: "x" | "y") {}
}
new Test().method("x");
`,
options: [{ useTsTypes: true }],
},
{
name: 'allows string literal union in multi-parameter function',
code: `
function test(first: string, second: "yes" | "no") {
test(first, "yes"); // second argument should be fine
}
`,
options: [{ useTsTypes: true }],
},
{
name: 'allows assignment to ignored member expression',
code: 'myObj.MY_PROP = "Hello World"',
Expand Down Expand Up @@ -276,12 +348,12 @@ ruleTester.run(name, rule, {
},
{
name: 'accepts TSAsExpression in uppercase',
code: 'const MY_AS = ("Hello" as string)',
code: 'const MY_AS = "Hello" as string',
options: [ignoreUpperCaseName],
},
{
name: 'accepts complex expressions in uppercase',
code: 'const MY_COMPLEX = !("Hello" as string) || `World ${name}`',
code: 'const MY_COMPLEX = !("Hello") || `World ${name}`',
options: [ignoreUpperCaseName],
},
{
Expand Down Expand Up @@ -322,6 +394,12 @@ ruleTester.run(name, rule, {
code: 'const message = "Select tax code"',
errors: defaultError,
},
{
name: 'detects unlocalized string literal with types active',
code: 'const message = "Select tax code"',
options: [{ useTsTypes: true }],
errors: defaultError,
},
{
name: 'detects unlocalized export',
code: 'export const text = "hello string";',
Expand Down Expand Up @@ -437,6 +515,68 @@ ruleTester.run(name, rule, {
options: [ignoreUpperCaseName],
errors: [{ messageId: 'default' }],
},
{
name: 'reports constants when missing TSASExpression in object',
code: 'const paramsByDropSide = { top: "above", bottom: "below" };',
errors: [{ messageId: 'default' }, { messageId: 'default' }],
},

// ==================== TypeScript Function Parameters ====================
{
name: 'handles function call with no parameters',
code: `
function noParams() {}
noParams("this should error");
`,
options: [{ useTsTypes: true }],
errors: [{ messageId: 'default' }],
},
{
name: 'handles function call with wrong number of arguments',
code: `
function oneParam(p: "a" | "b") {}
oneParam("a", "this should error");
`,
options: [{ useTsTypes: true }],
errors: [{ messageId: 'default' }],
},
{
name: 'handles function call where parameter is not a string literal type',
code: `
function stringParam(param: string) {}
stringParam("should report error");
`,
options: [{ useTsTypes: true }],
errors: defaultError,
},
{
name: 'handles method call where signature cannot be resolved',
code: `
const obj = { method: (x: any) => {} };
obj["method"]("should report error");
`,
options: [{ useTsTypes: true }],
errors: defaultError,
},
{
name: 'requires translation for non-union string parameters',
code: `
function test(first: string, second: "yes" | "no") {
test("needs translation", "yes");
}
`,
options: [{ useTsTypes: true }],
errors: [{ messageId: 'default' }],
},
{
name: 'handles type system error gracefully',
code: `
// This should cause type system issues but not crash
const x = (unknown as any).nonexistent("test");
`,
options: [{ useTsTypes: true }],
errors: [{ messageId: 'default' }],
},
],
})

Expand Down

0 comments on commit 04b198e

Please sign in to comment.