From 1a7824a290fc97a681813ac21bb3ca2642b089f0 Mon Sep 17 00:00:00 2001 From: Michael Wuergler Date: Sun, 2 Jun 2024 01:19:30 +0200 Subject: [PATCH] feat: add no-invalid-variables rule --- jest.config.json | 3 +- package.json | 1 + src/classes/problem.class.ts | 12 +- src/rules/index.ts | 3 +- src/rules/no-invalid-variables/README.md | 95 ++++++ src/rules/no-invalid-variables/index.ts | 1 + .../no-invalid-variables.spec.ts | 289 ++++++++++++++++++ .../no-invalid-variables.ts | 145 +++++++++ src/rules/no-invalid-variables/problems.ts | 54 ++++ src/tests/fixtures/i18n/de.json | 8 +- src/tests/fixtures/i18n/en.json | 8 +- src/tests/fixtures/i18n/fr.json | 8 +- src/types.ts | 8 +- src/utils/variable-helpers.spec.ts | 26 ++ src/utils/variable-helpers.ts | 87 ++++++ 15 files changed, 735 insertions(+), 13 deletions(-) create mode 100644 src/rules/no-invalid-variables/README.md create mode 100644 src/rules/no-invalid-variables/index.ts create mode 100644 src/rules/no-invalid-variables/no-invalid-variables.spec.ts create mode 100644 src/rules/no-invalid-variables/no-invalid-variables.ts create mode 100644 src/rules/no-invalid-variables/problems.ts create mode 100644 src/utils/variable-helpers.spec.ts create mode 100644 src/utils/variable-helpers.ts diff --git a/jest.config.json b/jest.config.json index 62668f8..c8dd71a 100644 --- a/jest.config.json +++ b/jest.config.json @@ -1,5 +1,6 @@ { "extensionsToTreatAsEsm": [".ts"], "preset": "ts-jest", - "testEnvironment": "node" + "testEnvironment": "node", + "testPathIgnorePatterns": ["dist"] } diff --git a/package.json b/package.json index fb9a832..50b1a55 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "typescript": "^5.4.5" }, "dependencies": { + "@formatjs/icu-messageformat-parser": "^2.7.8", "chalk": "^4.0.0" } } diff --git a/src/classes/problem.class.ts b/src/classes/problem.class.ts index dcf476c..d87e414 100644 --- a/src/classes/problem.class.ts +++ b/src/classes/problem.class.ts @@ -5,8 +5,8 @@ class Problem { severity: RuleSeverity; locale: string; message: string; - expected?: string; - received?: string; + expected?: string | object | number; + received?: string | object | number; public constructor(builder: ProblemBuilder) { this.ruleMeta = builder.ruleMeta; @@ -29,8 +29,8 @@ class ProblemBuilder { locale!: string; url!: string; message!: string; - expected?: string; - received?: string; + expected?: string | object | number; + received?: string | object | number; withSeverity(severity: RuleSeverity): this { this.severity = severity; @@ -52,12 +52,12 @@ class ProblemBuilder { return this; } - withExpected(expected?: string): this { + withExpected(expected?: string | object | number): this { this.expected = expected; return this; } - withReceived(received?: string): this { + withReceived(received?: string | object | number): this { this.received = received; return this; } diff --git a/src/rules/index.ts b/src/rules/index.ts index f69d7a6..85dae3f 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -1,4 +1,5 @@ import { noEmptyMessages } from "./no-empty-messages/no-empty-messages.js"; import { noUntranslatedMessages } from "./no-untranslated-messages/index.js"; +import { noInvalidVariables } from "./no-invalid-variables/index.js"; -export { noUntranslatedMessages, noEmptyMessages }; +export { noUntranslatedMessages, noEmptyMessages, noInvalidVariables }; diff --git a/src/rules/no-invalid-variables/README.md b/src/rules/no-invalid-variables/README.md new file mode 100644 index 0000000..187f214 --- /dev/null +++ b/src/rules/no-invalid-variables/README.md @@ -0,0 +1,95 @@ +# no-invalid-variables + +## Rule Details + +All variables declared in the base messages file and any translated files must have valid syntax, and the variables declared in the base (source) messages file must exist in the translated string. + +Additionally, if there are variables declared in the translated files, those same variables must exist in the source string. + +### Key considerations + +1. Whitespace is ignored in the comparison, so these two variables would be seen as the same: `{foo}` and `{ foo }` +2. Variable comparisions are case-sensitive, so each of these variables will be seen as a different variable: `{foo}`, `{Foo}`, `{FOo}` and `{FOO}` +3. Strings with rouge brackets will trigger. For example, consider this string: `"Here is an opening bracket: {"`. The rule logic will see the opening bracket in the string and think there is a malformed variable syntax found. If you get these type of false positives, then you could consider using the `ignoreKeys` override in the configuation. + +Assuming `en.json` is where your default language is (the source file): + +❌ Example of **incorrect** setup for this rule (the `firstName` variable is missing or malformed): + +```js +en.json: { 'hello': 'Hi, {firstName}' } +fr.json: { 'hello': 'Salut, {}' } +de.json: { 'hello': 'Hallo, firstName}' } +``` + +❌ Example of **incorrect** setup for this rule (the `firstName` variable has different casing): + +```js +en.json: { 'hello': 'Hi, {firstName}' } +fr.json: { 'hello': 'Salut, {firstNAME}' } +de.json: { 'hello': 'Hallo, {FIRSTName}' } +``` + +❌ Example of **incorrect** setup for this rule (the `firstName` variable has been accidently translated): + +```js +en.json: { 'hello': 'Hi, {firstName}' } +fr.json: { 'hello': 'Salut, {préNom}' } +de.json: { 'hello': 'Hallo, {vorName}' } +``` + +❌ Example of **incorrect** setup for this rule (mismatched brackets): + +```js +en.json: { 'hello': 'Hi, {' } +fr.json: { 'hello': 'Salut, }' } +de.json: { 'hello': 'Hallo, {{}' } +``` + +❌ Example of **incorrect** setup for this rule (variables in the translated files do not exist in the source message): + +```js +en.json: { 'hello': 'Hi' } +fr.json: { 'hello': 'Salut, {firstName}' } +de.json: { 'hello': 'Hallo, {firstName}' } +``` + +✅ Examples of a **correct** setup for this rule (all variables are the same): + +```js +en.json: { 'hello': 'Hi, {firstName}' } +fr.json: { 'hello': 'Salut, {firstName}' } +de.json: { 'hello': 'Hallo, {firstName}' } +``` + +## Example Configuration + +Simple configuration where you just supply the severity level: + +```json +{ + "rules": { + "no-invalid-variables": "error" + } +} +``` + +Advanced configuration where you can pass extra configuration to the rule: + +```json +{ + "rules": { + "no-invalid-variables": { + "severity": "error", + "ignoreKeys": ["foo", "bar"] + } + } +} +``` + +> [!IMPORTANT] +> Be careful when using the `ignoreKeys` array: ignoring keys means means potentially ignoring real problems that can affect the UI/UX and reliability of your application. + +## Version + +This rule was introduced in i18n-validator v1.0.0. diff --git a/src/rules/no-invalid-variables/index.ts b/src/rules/no-invalid-variables/index.ts new file mode 100644 index 0000000..1feb8bf --- /dev/null +++ b/src/rules/no-invalid-variables/index.ts @@ -0,0 +1 @@ +export { noInvalidVariables } from "./no-invalid-variables"; diff --git a/src/rules/no-invalid-variables/no-invalid-variables.spec.ts b/src/rules/no-invalid-variables/no-invalid-variables.spec.ts new file mode 100644 index 0000000..b575d20 --- /dev/null +++ b/src/rules/no-invalid-variables/no-invalid-variables.spec.ts @@ -0,0 +1,289 @@ +import { createMockProblemReporter } from "../../tests/utils/test-helpers.ts"; +import { + Config, + RuleContext, + RuleSeverity, + TranslationFiles, +} from "../../types.js"; +import { noInvalidVariables } from "./no-invalid-variables.ts"; +import { + getMismatchedVariableFromSourceProblem, + getMissingVariableFromSourceProblem, + getInvalidVariableSyntaxProblem, +} from "./problems.ts"; + +const ruleMeta = noInvalidVariables.meta; +const rule = noInvalidVariables; + +const defaultLocale = "en"; + +const baseConfig: Config = { + defaultLocale, + sourceFile: "en.json", + translationFiles: { fr: "fr.json", de: "de.json" }, + pathToTranslatedFiles: "i18n", + rules: { + "no-invalid-variables": "error", + }, + dryRun: false, + enabled: true, +}; + +describe.each([["error"], ["warning"]])(`${rule.meta.name}`, (severityStr) => { + const severity = severityStr as unknown as RuleSeverity; + + const context: RuleContext = { + severity, + ignoreKeys: [], + }; + + it(`should report missing variables in translation files with ${severity}`, () => { + const problemReporter = createMockProblemReporter(); + + const translationFiles: TranslationFiles = { + en: { greeting: "[EN] {firstName}", farewell: "[EN] {firstName}" }, + fr: { greeting: "[FR]", farewell: "[FR]" }, + }; + + rule.run(translationFiles, baseConfig, problemReporter, context); + + const expectedProblem1 = getMissingVariableFromSourceProblem({ + key: "greeting", + locale: "fr", + severity, + ruleMeta, + expected: "firstName", + received: "", + }); + + const expectedProblem2 = getMissingVariableFromSourceProblem({ + key: "farewell", + locale: "fr", + severity, + ruleMeta, + expected: "firstName", + received: "", + }); + + expect(problemReporter.report).toHaveBeenCalledTimes(2); + expect(problemReporter.report).toHaveBeenCalledWith(expectedProblem1); + expect(problemReporter.report).toHaveBeenCalledWith(expectedProblem2); + }); + + it(`should report unexpected variables in translation files with ${severity}`, () => { + const problemReporter = createMockProblemReporter(); + + const translationFiles: TranslationFiles = { + en: { greeting: "[EN]", farewell: "[EN]" }, + fr: { + greeting: "[FR] {unexpectedVariable}", + farewell: "[FR] {unexpectedVariable}", + }, + }; + + rule.run(translationFiles, baseConfig, problemReporter, context); + + const expectedProblem1 = getMismatchedVariableFromSourceProblem({ + key: "greeting", + locale: "fr", + severity, + ruleMeta, + expected: "", + received: "unexpectedVariable", + }); + + const expectedProblem2 = getMismatchedVariableFromSourceProblem({ + key: "farewell", + locale: "fr", + severity, + ruleMeta, + expected: "", + received: "unexpectedVariable", + }); + + expect(problemReporter.report).toHaveBeenCalledTimes(2); + expect(problemReporter.report).toHaveBeenCalledWith(expectedProblem1); + expect(problemReporter.report).toHaveBeenCalledWith(expectedProblem2); + }); + + it(`should report unbalanced variable brackets in the source file with ${severity}`, () => { + const problemReporter = createMockProblemReporter(); + + const translationFiles: TranslationFiles = { + en: { + greeting: "[EN] {greetingName", + farewell: "[EN] {farewellName", + }, + fr: { + greeting: "[FR] {greetingName}", + farewell: "[FR] {farewellName}", + }, + }; + + rule.run(translationFiles, baseConfig, problemReporter, context); + + const expectedProblem1 = getInvalidVariableSyntaxProblem({ + key: "greeting", + locale: "en", + severity, + ruleMeta, + expected: undefined, + received: "[EN] {greetingName", + }); + + const expectedProblem2 = getInvalidVariableSyntaxProblem({ + key: "farewell", + locale: "en", + severity, + ruleMeta, + expected: undefined, + received: "[EN] {farewellName", + }); + + const expectedProblem3 = getMismatchedVariableFromSourceProblem({ + key: "greeting", + locale: "fr", + severity, + ruleMeta, + expected: "", + received: "greetingName", + }); + + const expectedProblem4 = getMismatchedVariableFromSourceProblem({ + key: "farewell", + locale: "fr", + severity, + ruleMeta, + expected: "", + received: "farewellName", + }); + + expect(problemReporter.report).toHaveBeenCalledTimes(4); + expect(problemReporter.report).toHaveBeenCalledWith(expectedProblem1); + expect(problemReporter.report).toHaveBeenCalledWith(expectedProblem2); + expect(problemReporter.report).toHaveBeenCalledWith(expectedProblem3); + expect(problemReporter.report).toHaveBeenCalledWith(expectedProblem4); + }); + + it(`should ignore keys in ignoreKeys with severity ${severity}`, () => { + const problemReporter = createMockProblemReporter(); + + const translationFiles: TranslationFiles = { + en: { greeting: "[EN] {firstName}", farewell: "[EN] {firstName}" }, + fr: { greeting: "[FR]", farewell: "[FR]" }, + }; + + const ignoreKeysContext = { + ...context, + ignoreKeys: ["farewell", "greeting"], + }; + + rule.run(translationFiles, baseConfig, problemReporter, ignoreKeysContext); + + expect(problemReporter.report).not.toHaveBeenCalled(); + }); + + it(`should not report problems for keys to ignore with severity ${severity}`, () => { + const problemReporter = createMockProblemReporter(); + + const translationFiles: TranslationFiles = { + en: { + greeting: "[EN] {firstName}", + farewell: "[EN] {firstName}", + chocolate: "[EN] {chocolate}", + }, + fr: { greeting: "[FR]", farewell: "[FR]", chocolate: "[FR]" }, + de: { greeting: "[DE]", farewell: "[DE]", chocolate: "[DE]" }, + }; + + const expectedProblem1 = getMissingVariableFromSourceProblem({ + key: "greeting", + locale: "fr", + severity, + ruleMeta, + expected: "firstName", + received: "", + }); + + const expectedProblem2 = getMissingVariableFromSourceProblem({ + key: "greeting", + locale: "de", + severity, + ruleMeta, + expected: "firstName", + received: "", + }); + + const ignoredProblem1 = getMissingVariableFromSourceProblem({ + key: "chocolate", + locale: "fr", + severity, + ruleMeta, + expected: "firstName", + received: "", + }); + + const ignoredProblem2 = getMissingVariableFromSourceProblem({ + key: "chocolate", + locale: "de", + severity, + ruleMeta, + expected: "chocolate", + received: "", + }); + + const ignoredProblem3 = getMissingVariableFromSourceProblem({ + key: "farewell", + locale: "fr", + severity, + ruleMeta, + expected: "firstName", + received: "", + }); + + const ignoredProblem4 = getMissingVariableFromSourceProblem({ + key: "farewell", + locale: "de", + severity, + ruleMeta, + expected: "firstName", + received: "", + }); + + const ignoreKeysContext = { + ...context, + ignoreKeys: ["farewell", "chocolate"], + }; + + rule.run(translationFiles, baseConfig, problemReporter, ignoreKeysContext); + + expect(problemReporter.report).not.toHaveBeenCalledWith(ignoredProblem1); + expect(problemReporter.report).not.toHaveBeenCalledWith(ignoredProblem2); + expect(problemReporter.report).not.toHaveBeenCalledWith(ignoredProblem3); + expect(problemReporter.report).not.toHaveBeenCalledWith(ignoredProblem4); + expect(problemReporter.report).toHaveBeenCalledWith(expectedProblem1); + expect(problemReporter.report).toHaveBeenCalledWith(expectedProblem2); + }); +}); + +describe(`${rule.meta.name}: off`, () => { + const problemReporter = createMockProblemReporter(); + + const context: RuleContext = { + severity: "off", + ignoreKeys: [], + }; + + const translationFiles: TranslationFiles = { + en: { + a: "[EN] {a}", + b: "[EN] {b}", + c: "[EN] {c}", + }, + fr: { a: "[FR]", b: "[FR]", c: "[FR]" }, + de: { a: "[DE]", b: "[FR]", c: "[DE]" }, + }; + + rule.run(translationFiles, baseConfig, problemReporter, context); + expect(problemReporter.report).not.toHaveBeenCalled(); +}); diff --git a/src/rules/no-invalid-variables/no-invalid-variables.ts b/src/rules/no-invalid-variables/no-invalid-variables.ts new file mode 100644 index 0000000..34c83e2 --- /dev/null +++ b/src/rules/no-invalid-variables/no-invalid-variables.ts @@ -0,0 +1,145 @@ +import { SEVERITY_LEVEL } from "../../constants.ts"; +import { + Config, + Rule, + RuleContext, + RuleMeta, + TranslationFiles, +} from "../../types.ts"; +import { + extractVariableNamesFromMessage, + extractVariablesFromLocaleData, + hasUnbalancedBrackets, +} from "../../utils/variable-helpers.ts"; +import { + getMissingVariableFromSourceProblem, + getMismatchedVariableFromSourceProblem, + getInvalidVariableSyntaxProblem, +} from "./problems.ts"; + +const ruleMeta: RuleMeta = { + name: "no-invalid-variables", + description: `All variables in each of the translation files must match the variables declared in the source file and have a valid syntax.`, + url: "TBD", + type: "validation", + defaultSeverity: "error", +}; + +const noInvalidVariables: Rule = { + meta: ruleMeta, + run: ( + translationFiles: TranslationFiles, + config: Config, + problemReporter, + context: RuleContext + ) => { + const { defaultLocale } = config; + const { severity, ignoreKeys } = context; + const baseLocale = translationFiles[config.defaultLocale]; + + if (severity === SEVERITY_LEVEL.off) { + return; + } + + // Extract the valid variables from the base messages file. + // These variables are expected to be in each translated file. + const baseMessageVariables = extractVariablesFromLocaleData(baseLocale); + + for (let [locale, data] of Object.entries(translationFiles)) { + for (let [key, value] of Object.entries(data)) { + if (ignoreKeys.includes(key)) { + continue; + } + + // Check all files for unbalanced brackets, which can lead to syntax errors + // We report these first since if they exist they tend to mess with the other + // rules. If these problems get fixed by the user first then a few of the other + // errors might get cleared up on their own. Note: that we have to do this check + // seperate from the icu.parse() call, since the parser does not see trailing + // closing brackets as a problem. + if (hasUnbalancedBrackets(value)) { + problemReporter.report( + getInvalidVariableSyntaxProblem({ + key, + locale, + severity, + ruleMeta, + // TODO: highlight the area where the problem occured + // since the error comes with location offsets where the error is found + received: value, + }) + ); + } + + // Now do all the checks on the translated messages. + if (locale !== defaultLocale) { + let translatedVariables; + + try { + translatedVariables = extractVariableNamesFromMessage(value); + } catch (err: unknown) { + problemReporter.report( + getInvalidVariableSyntaxProblem({ + key, + locale, + severity, + ruleMeta, + // TODO: highlight the area where the problem occured + // since the error comes with location offsets where the error is found + received: value, + }) + ); + } + + const baseMessageHasVariables = Array.isArray( + baseMessageVariables[key] + ); + + // Check if this translated key is expected to have variables + if (baseMessageHasVariables) { + for (let baseMessageVariable of baseMessageVariables[key]) { + if ( + !translatedVariables || + !translatedVariables?.includes(baseMessageVariable) + ) { + problemReporter.report( + getMissingVariableFromSourceProblem({ + key, + locale, + severity, + ruleMeta, + expected: baseMessageVariable, + received: translatedVariables ?? "", + }) + ); + } + } + } + + // Check if the translated file has variables not defined in the base message + translatedVariables && + translatedVariables.forEach((translatedVariable) => { + if ( + !Array.isArray(baseMessageVariables[key]) || + (Array.isArray(baseMessageVariables[key]) && + !baseMessageVariables[key].includes(translatedVariable)) + ) { + problemReporter.report( + getMismatchedVariableFromSourceProblem({ + key, + locale, + severity, + ruleMeta, + expected: baseMessageVariables[key] ?? "", + received: translatedVariable, + }) + ); + } + }); + } + } + } + }, +}; + +export { noInvalidVariables }; diff --git a/src/rules/no-invalid-variables/problems.ts b/src/rules/no-invalid-variables/problems.ts new file mode 100644 index 0000000..96d2b67 --- /dev/null +++ b/src/rules/no-invalid-variables/problems.ts @@ -0,0 +1,54 @@ +import { Problem } from "../../classes/problem.class"; +import { RuleMeta, RuleSeverity } from "../../types"; + +type ProblemContext = { + key: string; + locale: string; + severity: RuleSeverity; + ruleMeta: RuleMeta; + expected?: string | object | number; + received?: string | object | number; +}; + +export function getMissingVariableFromSourceProblem( + problemContext: ProblemContext +): Problem { + const { key, locale, severity, ruleMeta, expected, received } = + problemContext; + + return Problem.Builder.withRuleMeta(ruleMeta) + .withSeverity(severity) + .withLocale(locale) + .withMessage(`Missing variable for key: ${key}`) + .withExpected(expected) + .withReceived(received) + .build(); +} + +export function getMismatchedVariableFromSourceProblem( + problemContext: ProblemContext +): Problem { + const { key, locale, severity, ruleMeta, expected, received } = + problemContext; + + return Problem.Builder.withRuleMeta(ruleMeta) + .withSeverity(severity) + .withLocale(locale) + .withMessage(`Unknown variable found in translation file for key: ${key}`) + .withExpected(expected) + .withReceived(received) + .build(); +} + +export function getInvalidVariableSyntaxProblem( + problemContext: ProblemContext +): Problem { + const { key, locale, severity, ruleMeta, received } = problemContext; + + return Problem.Builder.withRuleMeta(ruleMeta) + .withSeverity(severity) + .withLocale(locale) + .withMessage(`Invalid variable syntax found in key: ${key}`) + .withReceived(received) + .build(); +} diff --git a/src/tests/fixtures/i18n/de.json b/src/tests/fixtures/i18n/de.json index da7dc63..7b0e9dd 100644 --- a/src/tests/fixtures/i18n/de.json +++ b/src/tests/fixtures/i18n/de.json @@ -1,5 +1,11 @@ { "untranslated-message": "Hi!", "empty-string": "", - "only-empty-in-en": "[DE] not empty" + "only-empty-in-en": "[DE] not empty", + "declares-a-valid-variable": "Hallo, {firstName}", + "declares-an-unbalanced-variable-in-en": "[DE] unbalanced {variable}", + "declares-another-unbalanced-variable-in-en": "[DE] another unbalanced {variable}", + "declares-a-missing-variable-in-fr": "[DE] missing {variable}", + "declares-a-single-missing-variable-in-fr": "[DE] should be two {one} {two}", + "declares-a-malfomed-variable-in-de": "[DE] malformed {variable" } diff --git a/src/tests/fixtures/i18n/en.json b/src/tests/fixtures/i18n/en.json index d657e50..d99459c 100644 --- a/src/tests/fixtures/i18n/en.json +++ b/src/tests/fixtures/i18n/en.json @@ -1,5 +1,11 @@ { "untranslated-message": "Hi!", "empty-string": "", - "only-empty-in-en": "" + "only-empty-in-en": "", + "declares-a-valid-variable": "Hi, {firstName}", + "declares-an-unbalanced-variable-in-en": "[EN] unbalanced variable}", + "declares-another-unbalanced-variable-in-en": "[EN] another unbalanced {variable", + "declares-a-missing-variable-in-fr": "[EN] missing {variable}", + "declares-a-single-missing-variable-in-fr": "[EN] should be two {one} {two}", + "declares-a-malfomed-variable-in-de": "[EN] malformed {variable}" } diff --git a/src/tests/fixtures/i18n/fr.json b/src/tests/fixtures/i18n/fr.json index cf8d413..3bd183c 100644 --- a/src/tests/fixtures/i18n/fr.json +++ b/src/tests/fixtures/i18n/fr.json @@ -1,5 +1,11 @@ { "untranslated-message": "Hi!", "empty-string": "", - "only-empty-in-en": "[FR] not empty" + "only-empty-in-en": "[FR] not empty", + "declares-a-valid-variable": "Salut, {firstName}", + "declares-an-unbalanced-variable-in-en": "[FR] unbalanced {variable}", + "declares-another-unbalanced-variable-in-en": "[FR] another unbalanced {variable}", + "declares-a-missing-variable-in-fr": "[FR] missing", + "declares-a-single-missing-variable-in-fr": "[FR] should be two {one}", + "declares-a-malfomed-variable-in-de": "[FR] malformed {variable}" } diff --git a/src/types.ts b/src/types.ts index 367ea50..b525f3a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,10 +24,14 @@ export type Problem = { severity: RuleSeverity; locale: string; message: string; - expected?: string; - recieved?: string; + expected?: string | number | object; + received?: string | number | object; }; +export interface ProblemContext extends Problem { + key: string; +} + export type RuleMeta = { name: string; defaultSeverity: RuleSeverity; diff --git a/src/utils/variable-helpers.spec.ts b/src/utils/variable-helpers.spec.ts new file mode 100644 index 0000000..6cdae6f --- /dev/null +++ b/src/utils/variable-helpers.spec.ts @@ -0,0 +1,26 @@ +import { hasUnbalancedBrackets } from "./variable-helpers"; + +describe("hasUnbalancedBrackets", () => { + it("should return true for strings with unbalanced brackets", () => { + expect(hasUnbalancedBrackets("{")).toBe(true); + expect(hasUnbalancedBrackets("}")).toBe(true); + expect(hasUnbalancedBrackets("{}}")).toBe(true); + expect(hasUnbalancedBrackets("{{}")).toBe(true); + expect(hasUnbalancedBrackets("{{var}")).toBe(true); + expect(hasUnbalancedBrackets("{var{var}")).toBe(true); + expect(hasUnbalancedBrackets("text with variable}")).toBe(true); + expect(hasUnbalancedBrackets("more text with variable}")).toBe(true); + expect(hasUnbalancedBrackets("more text with {variable")).toBe(true); + }); + + it("should return false for strings without unbalanced brackets", () => { + expect(hasUnbalancedBrackets("{}")).toBe(false); + expect(hasUnbalancedBrackets("{{}}")).toBe(false); + expect(hasUnbalancedBrackets("{{{var}}}")).toBe(false); + expect(hasUnbalancedBrackets("{{{{()}}}}")).toBe(false); + expect(hasUnbalancedBrackets("{{var}}")).toBe(false); + expect(hasUnbalancedBrackets("{var}{var}")).toBe(false); + expect(hasUnbalancedBrackets("text with {variable}")).toBe(false); + expect(hasUnbalancedBrackets("more text with {variable}")).toBe(false); + }); +}); diff --git a/src/utils/variable-helpers.ts b/src/utils/variable-helpers.ts new file mode 100644 index 0000000..77a13b4 --- /dev/null +++ b/src/utils/variable-helpers.ts @@ -0,0 +1,87 @@ +import type { MessageFormatElement } from "@formatjs/icu-messageformat-parser"; +import * as icu from "@formatjs/icu-messageformat-parser"; + +export function extractVariableNamesFromMessage( + message: string +): string[] | undefined { + const parsedString = icu.parse(message); + + const variables = parsedString + .filter((token) => token?.type === 1 && token.value) + // TODO: find the correct types for this parser result + // @ts-ignore - the "value" object is not recognized on the parser result + .map((token) => token.value); + + if (variables.length > 0) { + return variables; + } + + return undefined; +} + +export function extractVariablesFromLocaleData(localeData: { + [key: string]: {}; +}) { + const messageVariables: { [key: string]: string[] } = {}; + + // Extract all of the expected variables from the source file + for (let [key, value] of Object.entries(localeData)) { + let variables; + + if (typeof value === "string") { + try { + variables = extractVariableNamesFromMessage(value); + } catch (err: unknown) { + // Fail silently here. There are many rules that will catch the problems we could be silencing here. + // The main goal of this function is to extract the known/valid variables. + } + } + + if (variables) { + messageVariables[key] = variables; + } + } + + return messageVariables; +} + +/** + * Checks if a message string has unbalanced brackets. Unbalanced brackets results in invalid variables. + * @param message + * @returns boolean + */ +export function hasUnbalancedBrackets(message: string): boolean { + const openingBrackets = ["{"]; + const closingBrackets = ["}"]; + const matchingBrackets = { + "}": "{", + }; + const stack = []; + + if ( + (message.length === 1 && openingBrackets.includes(message)) || + closingBrackets.includes(message) + ) { + return true; + } + + for (let char of message.split("")) { + if (openingBrackets.includes(char)) { + stack.push(char); + } else if (closingBrackets.includes(char)) { + if (stack.length === 0) { + return true; + } else if ( + stack.length > 0 && + // @ts-expect-error + stack[stack.length - 1] === matchingBrackets[char] + ) { + stack.pop(); + } else { + return true; + } + } + } + + return stack.length !== 0; +}