feat: 支持检查i18n中key包含的占位符

This commit is contained in:
Pan Qiancheng 2026-01-06 16:28:07 +08:00
parent bafe3e0c36
commit 6f49d59ffd
2 changed files with 410 additions and 72 deletions

View File

@ -1572,37 +1572,55 @@ const loadI18nData = (dirPath) => {
return null;
};
/**
* 检查 key 是否存在于 i18n 数据中
* @param i18nData i18n 数据对象
* @param key i18n key支持点号分隔的嵌套路径
* @returns boolean
* i18n 值中提取占位符
* 支持格式%{key} {{key}}
*/
const extractPlaceholders = (value) => {
const placeholders = [];
// 匹配 %{xxx} 格式
const percentPattern = /%\{([^}]+)\}/g;
let match;
while ((match = percentPattern.exec(value)) !== null) {
placeholders.push(match[1]);
}
// 匹配 {{xxx}} 格式(如果需要)
const bracePattern = /\{\{([^}]+)\}\}/g;
while ((match = bracePattern.exec(value)) !== null) {
if (!placeholders.includes(match[1])) {
placeholders.push(match[1]);
}
}
return placeholders;
};
const isKeyExistsInI18nData = (i18nData, key) => {
if (!i18nData) {
return false;
return { exists: false, placeholders: [] };
}
// 辅助递归函数
const checkPath = (obj, remainingKey) => {
if (!obj || typeof obj !== 'object') {
return false;
return { exists: false, placeholders: [] };
}
// 如果剩余key完整存在于当前对象直接返回true
// 如果剩余key完整存在于当前对象
if (obj.hasOwnProperty(remainingKey)) {
return true;
const value = obj[remainingKey];
return {
exists: true,
value: typeof value === 'string' ? value : undefined,
placeholders: typeof value === 'string' ? extractPlaceholders(value) : []
};
}
// 尝试所有可能的分割点
for (let i = 1; i <= remainingKey.length; i++) {
const firstPart = remainingKey.substring(0, i);
const restPart = remainingKey.substring(i + 1); // +1 跳过点号
// 如果当前部分存在且后面还有内容(有点号分隔)
const restPart = remainingKey.substring(i + 1);
if (obj.hasOwnProperty(firstPart) && i < remainingKey.length && remainingKey[i] === '.') {
// 递归检查剩余部分
if (checkPath(obj[firstPart], restPart)) {
return true;
const result = checkPath(obj[firstPart], restPart);
if (result.exists) {
return result;
}
}
}
return false;
return { exists: false, placeholders: [] };
};
return checkPath(i18nData, key);
};
@ -1853,8 +1871,13 @@ const checkI18nKeys = (pwd, callSet, program, typeChecker) => {
const missingKeys = [];
const foundKeys = [];
for (const key of keyVariants) {
const exists = isKeyExistsInI18nData(i18nData, key) ||
checkWithCommonStringExists(i18nData, key);
const result = isKeyExistsInI18nData(i18nData, key);
const commonResult = result.exists ? result : checkWithCommonStringExists(i18nData, key);
const exists = commonResult.exists;
// 如果找到了 key检查占位符
if (exists && commonResult.placeholders.length > 0) {
checkSecondArgument(callNode, commonResult.placeholders, addDiagnostic);
}
if (exists) {
foundKeys.push(key);
}
@ -1935,7 +1958,93 @@ const checkI18nKeys = (pwd, callSet, program, typeChecker) => {
else {
return isKeyExistsInI18nData(i18nData, key);
}
return false;
return { exists: false, placeholders: [] };
};
/**
* 检查 t 函数的第二个参数是否提供了所需的占位符
*/
const checkSecondArgument = (callNode, placeholders, addDiagnostic) => {
if (placeholders.length === 0) {
return; // 没有占位符,不需要检查
}
const args = callNode.arguments;
if (args.length < 2) {
// 缺少第二个参数
addDiagnostic(callNode, `i18n 值包含占位符 ${placeholders.map(p => `%{${p}}`).join(', ')},但未提供第二个参数。`, 9210, {
reason: 'MissingSecondArgument',
reasonDetails: [
`✘ 需要的占位符: ${placeholders.join(', ')}`,
'建议: 添加第二个参数对象,如: t("key", { url: "..." })'
]
});
return;
}
const secondArg = args[1];
// 检查第二个参数是否是对象字面量
if (!ts.isObjectLiteralExpression(secondArg)) {
addDiagnostic(callNode, `i18n 值包含占位符,但第二个参数不是字面对象,无法检查占位符是否提供。`, 9211, {
reason: 'NonLiteralSecondArgument',
reasonDetails: [
`✘ 第二个参数类型: ${secondArg.getText()}`,
`需要的占位符: ${placeholders.join(', ')}`,
'建议: 使用对象字面量,如: { url: "..." }'
]
});
return;
}
// 提取对象字面量中的属性名
const providedKeys = new Set();
secondArg.properties.forEach(prop => {
if (ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) {
if (ts.isIdentifier(prop.name)) {
providedKeys.add(prop.name.text);
}
else if (ts.isStringLiteral(prop.name)) {
providedKeys.add(prop.name.text);
}
else if (ts.isComputedPropertyName(prop.name)) {
// 计算属性名,无法静态检查
// 可以选择跳过或警告
}
}
else if (ts.isSpreadAssignment(prop)) {
// 展开运算符,无法静态检查所有属性
// 可以选择跳过检查
return;
}
});
// 检查是否有展开运算符
const hasSpread = secondArg.properties.some(prop => ts.isSpreadAssignment(prop));
// 检查缺失的占位符
const missingPlaceholders = placeholders.filter(p => !providedKeys.has(p));
if (missingPlaceholders.length > 0) {
const details = [];
if (hasSpread) {
details.push('¡ 第二个参数包含展开运算符,可能提供了额外的属性');
}
details.push('需要的占位符:');
placeholders.forEach(p => {
if (providedKeys.has(p)) {
details.push(`${p} - 已提供`);
}
else {
details.push(`${p} - 缺失`);
}
});
if (!hasSpread) {
addDiagnostic(callNode, `i18n 第二个参数缺少占位符: ${missingPlaceholders.join(', ')}`, 9212, {
reason: 'MissingPlaceholders',
reasonDetails: details
});
}
else {
// 有展开运算符,降级为警告
addDiagnostic(callNode, `i18n 第二个参数可能缺少占位符: ${missingPlaceholders.join(', ')} (包含展开运算符,无法完全确定)`, 9213, {
reason: 'PossiblyMissingPlaceholders',
reasonDetails: details
});
}
}
};
/**
* 检查变量引用的 i18n key
@ -1949,8 +2058,19 @@ const checkI18nKeys = (pwd, callSet, program, typeChecker) => {
const missingKeys = [];
const foundKeys = [];
for (const value of analysis.literalValues) {
const exists = isKeyExistsInI18nData(i18nData, value) ||
checkWithCommonStringExists(i18nData, value);
const result = isKeyExistsInI18nData(i18nData, value);
const commonResult = result.exists ? result : checkWithCommonStringExists(i18nData, value);
const exists = commonResult.exists;
if (exists) {
foundKeys.push(value);
// 检查占位符(对于字面量联合类型,只在所有值都找到时检查)
if (commonResult.placeholders.length > 0) {
checkSecondArgument(callNode, commonResult.placeholders, addDiagnostic);
}
}
else {
missingKeys.push(value);
}
if (exists) {
foundKeys.push(value);
}
@ -2009,15 +2129,20 @@ const checkI18nKeys = (pwd, callSet, program, typeChecker) => {
}
};
const checkWithCommonString = (i18nData, key, callNode, localePath) => {
let result;
if (commonLocaleKeyRegex.test(key)) {
// 公共字符串,格式如 common::title, 这里把namespace提取出来
const parts = commonLocaleKeyRegex.exec(key);
if (parts && parts.length >= 2) {
const namespace = parts[1];
const localeData = getCommonLocaleData(namespace);
const actualKey = key.substring(namespace.length + 2); // 去掉 namespace 和 ::
if (isKeyExistsInI18nData(localeData, actualKey)) {
return; // 找到,直接返回
const actualKey = key.substring(namespace.length + 2);
result = isKeyExistsInI18nData(localeData, actualKey);
if (result.exists) {
// 检查占位符
if (result.placeholders.length > 0) {
checkSecondArgument(callNode, result.placeholders, addDiagnostic);
}
return;
}
else {
addDiagnostic(callNode, `i18n key "${key}" not found in public locale files: namespace "${namespace}".`, 9200);
@ -2035,14 +2160,18 @@ const checkI18nKeys = (pwd, callSet, program, typeChecker) => {
}
}
else if (entityLocaleKeyRegex.test(key)) {
// 实体字符串,格式如 entity:attr.name
const parts = entityLocaleKeyRegex.exec(key);
if (parts && parts.length >= 2) {
const entity = parts[1];
const localeData = getEntityLocaleData(entity);
const actualKey = key.substring(entity.length + 1);
if (isKeyExistsInI18nData(localeData, actualKey)) {
return; // 找到,直接返回
result = isKeyExistsInI18nData(localeData, actualKey);
if (result.exists) {
// 检查占位符
if (result.placeholders.length > 0) {
checkSecondArgument(callNode, result.placeholders, addDiagnostic);
}
return;
}
else {
addDiagnostic(callNode, `i18n key "${key}" not found in entity locale files: entity "${entity}".`, 9200);
@ -2060,9 +2189,13 @@ const checkI18nKeys = (pwd, callSet, program, typeChecker) => {
}
}
else {
// 普通字符串,直接检查
if (isKeyExistsInI18nData(i18nData, key)) {
return; // 找到,直接返回
result = isKeyExistsInI18nData(i18nData, key);
if (result.exists) {
// 检查占位符
if (result.placeholders.length > 0) {
checkSecondArgument(callNode, result.placeholders, addDiagnostic);
}
return;
}
else {
addDiagnostic(callNode, `i18n key "${key}" not found in its locale files: ${localePath}.`, 9200);
@ -2146,8 +2279,19 @@ const checkI18nKeys = (pwd, callSet, program, typeChecker) => {
const missingKeys = [];
const foundKeys = [];
for (const value of analysis.literalValues) {
const exists = isKeyExistsInI18nData(i18nData, value) ||
checkWithCommonStringExists(i18nData, value);
const result = isKeyExistsInI18nData(i18nData, value);
const commonResult = result.exists ? result : checkWithCommonStringExists(i18nData, value);
const exists = commonResult.exists;
if (exists) {
foundKeys.push(value);
// 检查占位符(对于字面量联合类型,只在所有值都找到时检查)
if (commonResult.placeholders.length > 0) {
checkSecondArgument(callNode, commonResult.placeholders, addDiagnostic);
}
}
else {
missingKeys.push(value);
}
if (exists) {
foundKeys.push(value);
}

View File

@ -1930,47 +1930,75 @@ const loadI18nData = (dirPath: string): Record<string, string> | null => {
return null;
}
/**
* key i18n
* @param i18nData i18n
* @param key i18n key
* @returns boolean
interface I18nKeyCheckResult {
exists: boolean;
value?: string;
placeholders: string[];
}
/**
* i18n
* %{key} {{key}}
*/
const isKeyExistsInI18nData = (i18nData: Record<string, string> | null, key: string): boolean => {
if (!i18nData) {
return false;
const extractPlaceholders = (value: string): string[] => {
const placeholders: string[] = [];
// 匹配 %{xxx} 格式
const percentPattern = /%\{([^}]+)\}/g;
let match;
while ((match = percentPattern.exec(value)) !== null) {
placeholders.push(match[1]);
}
// 辅助递归函数
const checkPath = (obj: any, remainingKey: string): boolean => {
// 匹配 {{xxx}} 格式(如果需要)
const bracePattern = /\{\{([^}]+)\}\}/g;
while ((match = bracePattern.exec(value)) !== null) {
if (!placeholders.includes(match[1])) {
placeholders.push(match[1]);
}
}
return placeholders;
};
const isKeyExistsInI18nData = (i18nData: Record<string, string> | null, key: string): I18nKeyCheckResult => {
if (!i18nData) {
return { exists: false, placeholders: [] };
}
const checkPath = (obj: any, remainingKey: string): I18nKeyCheckResult => {
if (!obj || typeof obj !== 'object') {
return false;
return { exists: false, placeholders: [] };
}
// 如果剩余key完整存在于当前对象直接返回true
// 如果剩余key完整存在于当前对象
if (obj.hasOwnProperty(remainingKey)) {
return true;
const value = obj[remainingKey];
return {
exists: true,
value: typeof value === 'string' ? value : undefined,
placeholders: typeof value === 'string' ? extractPlaceholders(value) : []
};
}
// 尝试所有可能的分割点
for (let i = 1; i <= remainingKey.length; i++) {
const firstPart = remainingKey.substring(0, i);
const restPart = remainingKey.substring(i + 1); // +1 跳过点号
const restPart = remainingKey.substring(i + 1);
// 如果当前部分存在且后面还有内容(有点号分隔)
if (obj.hasOwnProperty(firstPart) && i < remainingKey.length && remainingKey[i] === '.') {
// 递归检查剩余部分
if (checkPath(obj[firstPart], restPart)) {
return true;
const result = checkPath(obj[firstPart], restPart);
if (result.exists) {
return result;
}
}
}
return false;
return { exists: false, placeholders: [] };
};
return checkPath(i18nData, key);
}
};
// 表达式类型分析结果
interface TypeAnalysis {
@ -2282,8 +2310,14 @@ const checkI18nKeys = (pwd: string, callSet: Set<ts.CallExpression>, program: ts
const foundKeys: string[] = [];
for (const key of keyVariants) {
const exists = isKeyExistsInI18nData(i18nData, key) ||
checkWithCommonStringExists(i18nData, key);
const result = isKeyExistsInI18nData(i18nData, key);
const commonResult = result.exists ? result : checkWithCommonStringExists(i18nData, key);
const exists = commonResult.exists;
// 如果找到了 key检查占位符
if (exists && commonResult.placeholders.length > 0) {
checkSecondArgument(callNode, commonResult.placeholders, addDiagnostic);
}
if (exists) {
foundKeys.push(key);
@ -2360,7 +2394,7 @@ const checkI18nKeys = (pwd: string, callSet: Set<ts.CallExpression>, program: ts
/**
*
*/
const checkWithCommonStringExists = (i18nData: Record<string, string>, key: string): boolean => {
const checkWithCommonStringExists = (i18nData: Record<string, string>, key: string): I18nKeyCheckResult => {
if (commonLocaleKeyRegex.test(key)) {
const parts = commonLocaleKeyRegex.exec(key);
if (parts && parts.length >= 2) {
@ -2381,7 +2415,123 @@ const checkI18nKeys = (pwd: string, callSet: Set<ts.CallExpression>, program: ts
return isKeyExistsInI18nData(i18nData, key);
}
return false;
return { exists: false, placeholders: [] };
};
/**
* t
*/
const checkSecondArgument = (
callNode: ts.CallExpression,
placeholders: string[],
addDiagnostic: (node: ts.Node, message: string, code: number, options?: any) => void
): void => {
if (placeholders.length === 0) {
return; // 没有占位符,不需要检查
}
const args = callNode.arguments;
if (args.length < 2) {
// 缺少第二个参数
addDiagnostic(
callNode,
`i18n 值包含占位符 ${placeholders.map(p => `%{${p}}`).join(', ')},但未提供第二个参数。`,
9210,
{
reason: 'MissingSecondArgument',
reasonDetails: [
`✘ 需要的占位符: ${placeholders.join(', ')}`,
'建议: 添加第二个参数对象,如: t("key", { url: "..." })'
]
}
);
return;
}
const secondArg = args[1];
// 检查第二个参数是否是对象字面量
if (!ts.isObjectLiteralExpression(secondArg)) {
addDiagnostic(
callNode,
`i18n 值包含占位符,但第二个参数不是字面对象,无法检查占位符是否提供。`,
9211,
{
reason: 'NonLiteralSecondArgument',
reasonDetails: [
`✘ 第二个参数类型: ${secondArg.getText()}`,
`需要的占位符: ${placeholders.join(', ')}`,
'建议: 使用对象字面量,如: { url: "..." }'
]
}
);
return;
}
// 提取对象字面量中的属性名
const providedKeys = new Set<string>();
secondArg.properties.forEach(prop => {
if (ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) {
if (ts.isIdentifier(prop.name)) {
providedKeys.add(prop.name.text);
} else if (ts.isStringLiteral(prop.name)) {
providedKeys.add(prop.name.text);
} else if (ts.isComputedPropertyName(prop.name)) {
// 计算属性名,无法静态检查
// 可以选择跳过或警告
}
} else if (ts.isSpreadAssignment(prop)) {
// 展开运算符,无法静态检查所有属性
// 可以选择跳过检查
return;
}
});
// 检查是否有展开运算符
const hasSpread = secondArg.properties.some(prop => ts.isSpreadAssignment(prop));
// 检查缺失的占位符
const missingPlaceholders = placeholders.filter(p => !providedKeys.has(p));
if (missingPlaceholders.length > 0) {
const details: string[] = [];
if (hasSpread) {
details.push('¡ 第二个参数包含展开运算符,可能提供了额外的属性');
}
details.push('需要的占位符:');
placeholders.forEach(p => {
if (providedKeys.has(p)) {
details.push(`${p} - 已提供`);
} else {
details.push(`${p} - 缺失`);
}
});
if (!hasSpread) {
addDiagnostic(
callNode,
`i18n 第二个参数缺少占位符: ${missingPlaceholders.join(', ')}`,
9212,
{
reason: 'MissingPlaceholders',
reasonDetails: details
}
);
} else {
// 有展开运算符,降级为警告
addDiagnostic(
callNode,
`i18n 第二个参数可能缺少占位符: ${missingPlaceholders.join(', ')} (包含展开运算符,无法完全确定)`,
9213,
{
reason: 'PossiblyMissingPlaceholders',
reasonDetails: details
}
);
}
}
};
/**
@ -2406,8 +2556,19 @@ const checkI18nKeys = (pwd: string, callSet: Set<ts.CallExpression>, program: ts
const foundKeys: string[] = [];
for (const value of analysis.literalValues) {
const exists = isKeyExistsInI18nData(i18nData, value) ||
checkWithCommonStringExists(i18nData, value);
const result = isKeyExistsInI18nData(i18nData, value);
const commonResult = result.exists ? result : checkWithCommonStringExists(i18nData, value);
const exists = commonResult.exists;
if (exists) {
foundKeys.push(value);
// 检查占位符(对于字面量联合类型,只在所有值都找到时检查)
if (commonResult.placeholders.length > 0) {
checkSecondArgument(callNode, commonResult.placeholders, addDiagnostic);
}
} else {
missingKeys.push(value);
}
if (exists) {
foundKeys.push(value);
@ -2485,16 +2646,28 @@ const checkI18nKeys = (pwd: string, callSet: Set<ts.CallExpression>, program: ts
}
};
const checkWithCommonString = (i18nData: Record<string, string>, key: string, callNode: ts.CallExpression, localePath: string) => {
const checkWithCommonString = (
i18nData: Record<string, string>,
key: string,
callNode: ts.CallExpression,
localePath: string
) => {
let result: I18nKeyCheckResult;
if (commonLocaleKeyRegex.test(key)) {
// 公共字符串,格式如 common::title, 这里把namespace提取出来
const parts = commonLocaleKeyRegex.exec(key);
if (parts && parts.length >= 2) {
const namespace = parts[1];
const localeData = getCommonLocaleData(namespace);
const actualKey = key.substring(namespace.length + 2); // 去掉 namespace 和 ::
if (isKeyExistsInI18nData(localeData, actualKey)) {
return; // 找到,直接返回
const actualKey = key.substring(namespace.length + 2);
result = isKeyExistsInI18nData(localeData, actualKey);
if (result.exists) {
// 检查占位符
if (result.placeholders.length > 0) {
checkSecondArgument(callNode, result.placeholders, addDiagnostic);
}
return;
} else {
addDiagnostic(
callNode,
@ -2518,14 +2691,19 @@ const checkI18nKeys = (pwd: string, callSet: Set<ts.CallExpression>, program: ts
return;
}
} else if (entityLocaleKeyRegex.test(key)) {
// 实体字符串,格式如 entity:attr.name
const parts = entityLocaleKeyRegex.exec(key);
if (parts && parts.length >= 2) {
const entity = parts[1];
const localeData = getEntityLocaleData(entity);
const actualKey = key.substring(entity.length + 1);
if (isKeyExistsInI18nData(localeData, actualKey)) {
return; // 找到,直接返回
result = isKeyExistsInI18nData(localeData, actualKey);
if (result.exists) {
// 检查占位符
if (result.placeholders.length > 0) {
checkSecondArgument(callNode, result.placeholders, addDiagnostic);
}
return;
} else {
addDiagnostic(
callNode,
@ -2549,9 +2727,14 @@ const checkI18nKeys = (pwd: string, callSet: Set<ts.CallExpression>, program: ts
return;
}
} else {
// 普通字符串,直接检查
if (isKeyExistsInI18nData(i18nData, key)) {
return; // 找到,直接返回
result = isKeyExistsInI18nData(i18nData, key);
if (result.exists) {
// 检查占位符
if (result.placeholders.length > 0) {
checkSecondArgument(callNode, result.placeholders, addDiagnostic);
}
return;
} else {
addDiagnostic(
callNode,
@ -2561,7 +2744,7 @@ const checkI18nKeys = (pwd: string, callSet: Set<ts.CallExpression>, program: ts
return;
}
}
}
};
// 逐文件处理
groupedCalls.forEach((calls, filePath) => {
@ -2656,8 +2839,19 @@ const checkI18nKeys = (pwd: string, callSet: Set<ts.CallExpression>, program: ts
const foundKeys: string[] = [];
for (const value of analysis.literalValues) {
const exists = isKeyExistsInI18nData(i18nData, value) ||
checkWithCommonStringExists(i18nData, value);
const result = isKeyExistsInI18nData(i18nData, value);
const commonResult = result.exists ? result : checkWithCommonStringExists(i18nData, value);
const exists = commonResult.exists;
if (exists) {
foundKeys.push(value);
// 检查占位符(对于字面量联合类型,只在所有值都找到时检查)
if (commonResult.placeholders.length > 0) {
checkSecondArgument(callNode, commonResult.placeholders, addDiagnostic);
}
} else {
missingKeys.push(value);
}
if (exists) {
foundKeys.push(value);