Repository: vendure-ecommerce/ngx-translate-extract
Pull request #41 (2024-03-08 15:02:22)

Make sort sensitivity opt-in and configurable
In a recent PR (#35), the sorting of strings was adjusted to be case-insensitive. This is not an option for us, so I made the functionality optional with the parameter `--sort-sensitivity`. If not provided, the old behavior is re-applied.

The unit tests for the postprocessor so they have been updated and expanded

bjarne-callewaert: Thanks for the feedback. I pushed some changes to my initial PR with the proposed additions.

Patch:
diff --git a/README.md b/README.md
index ebcfbfb..839d43b 100644
--- a/README.md
+++ b/README.md
@@ -88,6 +88,16 @@ If you want to use spaces instead, you can do the following:
 ngx-translate-extract --input ./src --output ./src/i18n/en.json --format-indentation ' '
 ```
 
+### Sorting
+
+Extracted keys are by default not sorted. You can enable sorting by using the `--sort` or `-s` flag.
+
+If sorting is enabled, the keys will be sorted using the default variant sort sensitivity. Other sort sensitivity options are also available using the `--sort-sensitivity` or `-ss` flag:
+- `base`: Strings that differ in base letters are unequal. For example `a !== b`, `a === á`, `a === A`
+- `accent`: Strings that differ in base letters and accents are unequal. For example `a !== b`, `a !== á`, `a === A`
+- `case`: Strings that differ in base letters or casing are unequal. For example `a !== b`, `a === á`, `a !== A`
+- `variant`: Strings that differ in base letters, accents, or casing are unequal. For example `a !== b`, `a !== á`, `a !== A`
+
 ### Marker function
 
 If you want to extract strings that are not passed directly to `NgxTranslate.TranslateService`'s
@@ -110,6 +120,7 @@ Output
   --format, -f                Format        [string] [choices: "json", "namespaced-json", "pot"] [default: "json"]
   --format-indentation, --fi  Format indentation (JSON/Namedspaced JSON)                  [string] [default: "\t"]
   --sort, -s                  Sort strings in alphabetical order                                         [boolean]
+  --sort-sensitivity, -ss     Sensitivity when sorting strings (only when sort is enabled)				 [string]
   --clean, -c                 Remove obsolete strings after merge                                        [boolean]
   --replace, -r               Replace the contents of output file if it exists (Merges by default)       [boolean]
   --strip-prefix, -sp         Strip prefix from key                                                       [string]
diff --git a/src/cli/cli.ts b/src/cli/cli.ts
index d486c38..6d7d937 100755
--- a/src/cli/cli.ts
+++ b/src/cli/cli.ts
@@ -81,6 +81,13 @@ const cli = await y
 		describe: 'Sort strings in alphabetical order',
 		type: 'boolean'
 	})
+	.option('sort-sensitivity', {
+		alias: 'ss',
+		describe: 'Sort sensitivitiy of strings (only to be used when sorting)',
+		type: 'string',
+		choices: ['base', 'accent', 'case', 'variant'],
+		default: undefined
+	})
 	.option('clean', {
 		alias: 'c',
 		describe: 'Remove obsolete strings after merge',
@@ -125,7 +132,7 @@ const cli = await y
 		describe: 'Strip a prefix from the extracted key',
 		type: 'string'
 	})
-	.group(['format', 'format-indentation', 'sort', 'clean', 'replace', 'strip-prefix'], 'Output')
+	.group(['format', 'format-indentation', 'sort', 'sort-sensitivity', 'clean', 'replace', 'strip-prefix'], 'Output')
 	.group(['key-as-default-value', 'key-as-initial-default-value', 'null-as-default-value', 'string-as-default-value'], 'Extracted key value (defaults to empty string)')
 	.conflicts('key-as-default-value', 'null-as-default-value')
 	.conflicts('key-as-initial-default-value', 'null-as-default-value')
@@ -177,7 +184,7 @@ if (cli.stripPrefix) {
 }
 
 if (cli.sort) {
-	postProcessors.push(new SortByKeyPostProcessor());
+	postProcessors.push(new SortByKeyPostProcessor(cli.sortSensitivity));
 }
 extractTask.setPostProcessors(postProcessors);
 
diff --git a/src/parsers/function.parser.ts b/src/parsers/function.parser.ts
index cfee515..85b2c52 100644
--- a/src/parsers/function.parser.ts
+++ b/src/parsers/function.parser.ts
@@ -1,8 +1,6 @@
-import { tsquery } from '@phenomnomnominal/tsquery';
-
 import { ParserInterface } from './parser.interface.js';
 import { TranslationCollection } from '../utils/translation.collection.js';
-import { getStringsFromExpression, findSimpleCallExpressions } from '../utils/ast-helpers.js';
+import { getStringsFromExpression, findSimpleCallExpressions, getAST } from '../utils/ast-helpers.js';
 import pkg from 'typescript';
 const { isIdentifier } = pkg;
 
@@ -10,7 +8,7 @@ export class FunctionParser implements ParserInterface {
 	constructor(private fnName: string) {}
 
 	public extract(source: string, filePath: string): TranslationCollection | null {
-		const sourceFile = tsquery.ast(source, filePath);
+		const sourceFile = getAST(source, filePath);
 
 		let collection: TranslationCollection = new TranslationCollection();
 
diff --git a/src/parsers/marker.parser.ts b/src/parsers/marker.parser.ts
index c9812da..0d1e339 100644
--- a/src/parsers/marker.parser.ts
+++ b/src/parsers/marker.parser.ts
@@ -1,25 +1,13 @@
-import { ScriptKind, tsquery } from '@phenomnomnominal/tsquery';
-import { extname } from 'path';
-
 import { ParserInterface } from './parser.interface.js';
 import { TranslationCollection } from '../utils/translation.collection.js';
-import { getNamedImportAlias, findFunctionCallExpressions, getStringsFromExpression } from '../utils/ast-helpers.js';
+import { getNamedImportAlias, findFunctionCallExpressions, getStringsFromExpression, getAST } from '../utils/ast-helpers.js';
 
 const MARKER_MODULE_NAME = 'ngx-translate-extract-marker';
 const MARKER_IMPORT_NAME = 'marker';
 
 export class MarkerParser implements ParserInterface {
 	public extract(source: string, filePath: string): TranslationCollection | null {
-		const supportedScriptTypes: Record<string, ScriptKind> = {
-			'.js': ScriptKind.JS,
-			'.jsx': ScriptKind.JSX,
-			'.ts': ScriptKind.TS,
-			'.tsx': ScriptKind.TSX
-		};
-
-		const scriptKind = supportedScriptTypes[extname(filePath)] ?? ScriptKind.TS;
-
-		const sourceFile = tsquery.ast(source, filePath, scriptKind);
+		const sourceFile = getAST(source, filePath);
 
 		const markerImportName = getNamedImportAlias(sourceFile, MARKER_MODULE_NAME, MARKER_IMPORT_NAME);
 		if (!markerImportName) {
diff --git a/src/parsers/service.parser.ts b/src/parsers/service.parser.ts
index d2cbb21..349488f 100644
--- a/src/parsers/service.parser.ts
+++ b/src/parsers/service.parser.ts
@@ -4,7 +4,6 @@ import fs from 'node:fs';
 import { ClassDeclaration, CallExpression, SourceFile } from 'typescript';
 import { resolveSync } from 'tsconfig';
 import JSON5 from 'json5';
-import { tsquery } from '@phenomnomnominal/tsquery';
 
 import { ParserInterface } from './parser.interface.js';
 import { TranslationCollection } from '../utils/translation.collection.js';
@@ -19,7 +18,8 @@ import {
 	getSuperClassName,
 	getImportPath,
 	findFunctionExpressions,
-	findVariableNameByInjectType
+	findVariableNameByInjectType,
+	getAST
 } from '../utils/ast-helpers.js';
 
 const TRANSLATE_SERVICE_TYPE_REFERENCE = 'TranslateService';
@@ -29,7 +29,7 @@ export class ServiceParser implements ParserInterface {
 	private static propertyMap = new Map<string, string[]>();
 
 	public extract(source: string, filePath: string): TranslationCollection | null {
-		const sourceFile = tsquery.ast(source, filePath);
+		const sourceFile = getAST(source, filePath);
 		const classDeclarations = findClassDeclarations(sourceFile);
 		const functionDeclarations = findFunctionExpressions(sourceFile);
 
@@ -141,7 +141,7 @@ export class ServiceParser implements ParserInterface {
 		const allSuperClassPropertyNames: string[] = [];
 		potentialSuperFiles.forEach((file) => {
 			const superClassFileContent = fs.readFileSync(file, 'utf8');
-			const superClassAst = tsquery.ast(superClassFileContent, file);
+			const superClassAst = getAST(superClassFileContent, file);
 			const superClassDeclarations = findClassDeclarations(superClassAst, superClassName);
 			const superClassPropertyNames = superClassDeclarations
 				.flatMap((superClassDeclaration) => findClassPropertiesByType(superClassDeclaration, TRANSLATE_SERVICE_TYPE_REFERENCE));
diff --git a/src/post-processors/sort-by-key.post-processor.ts b/src/post-processors/sort-by-key.post-processor.ts
index 6786b52..b1fd13f 100644
--- a/src/post-processors/sort-by-key.post-processor.ts
+++ b/src/post-processors/sort-by-key.post-processor.ts
@@ -4,8 +4,24 @@ import { PostProcessorInterface } from './post-processor.interface.js';
 export class SortByKeyPostProcessor implements PostProcessorInterface {
 	public name: string = 'SortByKey';
 
+	// More information on sort sensitivity: https://tc39.es/ecma402/#sec-collator-comparestrings
+	// Passing undefined will be treated as 'variant' by default: https://tc39.es/ecma402/#sec-intl.collator
+	public sortSensitivity: 'base' | 'accent' | 'case' | 'variant' | undefined = undefined;
+
+	constructor(sortSensitivity: string | undefined) {
+		if (isOfTypeSortSensitivity(sortSensitivity)) {
+			this.sortSensitivity = sortSensitivity;
+		} else {
+			throw new Error(`Unknown sortSensitivity: ${sortSensitivity}`);
+		}
+	}
+
 	public process(draft: TranslationCollection, extracted: TranslationCollection, existing: TranslationCollection): TranslationCollection {
-		const compareFn = new Intl.Collator('en', {sensitivity: 'base'}).compare;
+		const compareFn = this.sortSensitivity ? new Intl.Collator('en', { sensitivity: this.sortSensitivity }).compare : undefined;
 		return draft.sort(compareFn);
 	}
 }
+
+function isOfTypeSortSensitivity(keyInput: string | undefined): keyInput is 'base' | 'accent' | 'case' | 'variant' | undefined {
+	return ['base', 'accent', 'case', 'variant'].includes(keyInput) || keyInput === undefined;
+}
diff --git a/src/utils/ast-helpers.ts b/src/utils/ast-helpers.ts
index 6c9e6ee..9df125f 100644
--- a/src/utils/ast-helpers.ts
+++ b/src/utils/ast-helpers.ts
@@ -1,4 +1,5 @@
-import { tsquery } from '@phenomnomnominal/tsquery';
+import { extname } from 'node:path';
+import { ScriptKind, tsquery } from '@phenomnomnominal/tsquery';
 import pkg, {
 	Node,
 	NamedImports,
@@ -7,11 +8,24 @@ import pkg, {
 	ConstructorDeclaration,
 	CallExpression,
 	Expression,
-	PropertyAccessExpression,
-	StringLiteral
+	StringLiteral,
+	SourceFile
 } from 'typescript';
 const { SyntaxKind, isStringLiteralLike, isArrayLiteralExpression, isBinaryExpression, isConditionalExpression } = pkg;
 
+export function getAST(source: string, fileName = ''): SourceFile {
+	const supportedScriptTypes: Record<string, ScriptKind> = {
+		'.js': ScriptKind.JS,
+		'.jsx': ScriptKind.JSX,
+		'.ts': ScriptKind.TS,
+		'.tsx': ScriptKind.TSX
+	};
+
+	const scriptKind = supportedScriptTypes[extname(fileName)] ?? ScriptKind.TS;
+
+	return tsquery.ast(source, fileName, scriptKind);
+}
+
 export function getNamedImports(node: Node, moduleName: string): NamedImports[] {
 	const query = `ImportDeclaration[moduleSpecifier.text=/${moduleName}/] NamedImports`;
 	return tsquery<NamedImports>(node, query);


Test patch:
diff --git a/tests/parsers/function.parser.spec.ts b/tests/parsers/function.parser.spec.ts
index eb4bc66..c87050f 100644
--- a/tests/parsers/function.parser.spec.ts
+++ b/tests/parsers/function.parser.spec.ts
@@ -58,4 +58,20 @@ describe('FunctionParser', () => {
 		expect(keys).to.deep.equal(['DYNAMIC_TRAD.val1', 'DYNAMIC_TRAD.val2']);
 	});
 
+	it('should not break after bracket syntax casting', () => {
+		const contents = `
+		export class AppModule {
+			constructor() {
+			    const input: unknown = 'hello';    
+			    const myNiceVar1 = input as string;
+			    MK('hello.after.as.syntax');
+			
+			    const myNiceVar2 = <string>input;
+			    MK('hello.after.bracket.syntax');
+			}
+		}
+		`;
+		const keys = parser.extract(contents, componentFilename).keys();
+		expect(keys).to.deep.equal([ 'hello.after.as.syntax', 'hello.after.bracket.syntax']);
+	});
 });
diff --git a/tests/parsers/service.parser.spec.ts b/tests/parsers/service.parser.spec.ts
index 66bcc86..ea7ee3e 100644
--- a/tests/parsers/service.parser.spec.ts
+++ b/tests/parsers/service.parser.spec.ts
@@ -548,6 +548,29 @@ describe('ServiceParser', () => {
 		expect(keys).to.deep.equal([]);
 	});
 
+	it('should not break after bracket syntax casting', () => {
+		const contents = `
+			@Component({ })
+			export class AppComponent {
+				@Input()
+			    set color(value: unknown) {
+					const newValue = <string>value;
+				    this._color = value;
+				    
+					this._translateService.instant('hello.from.input.setter');
+			    }
+				_color: unknown;
+
+				constructor(protected _translateService: TranslateService) { }
+
+				method() {
+					this._translateService.instant('hello.from.method');
+				}
+		`;
+		const keys = parser.extract(contents, componentFilename)?.keys();
+		expect(keys).to.deep.equal(['hello.from.input.setter', 'hello.from.method']);
+	});
+
 	describe('function expressions', () => {
 		it('should extract from arrow function expression', () => {
 			const contents = `
diff --git a/tests/post-processors/sort-by-key.post-processor.spec.ts b/tests/post-processors/sort-by-key.post-processor.spec.ts
index f6ee4b2..29b2c01 100644
--- a/tests/post-processors/sort-by-key.post-processor.spec.ts
+++ b/tests/post-processors/sort-by-key.post-processor.spec.ts
@@ -4,37 +4,48 @@ import { PostProcessorInterface } from '../../src/post-processors/post-processor
 import { SortByKeyPostProcessor } from '../../src/post-processors/sort-by-key.post-processor.js';
 import { TranslationCollection } from '../../src/utils/translation.collection.js';
 
-describe('SortByKeyPostProcessor', () => {
+describe('SortByKeyPostProcessor - should throw error if sort sensitivity is not known', () => {
+	it('should throw error', () => {
+		expect(() => new SortByKeyPostProcessor('invalidSortSensitivityOption')).throw('Unknown sortSensitivity: invalidSortSensitivityOption');
+	});
+});
+
+describe('SortByKeyPostProcessor - undefined sort sensitivity should sort as variant sort sensitivity', () => {
 	let processor: PostProcessorInterface;
 
 	beforeEach(() => {
-		processor = new SortByKeyPostProcessor();
+		processor = new SortByKeyPostProcessor(undefined);
 	});
 
 	it('should sort keys alphanumerically', () => {
 		const collection = new TranslationCollection({
-			z: {value: 'last value', sourceFiles: []},
-			a: {value: 'a value', sourceFiles: []},
-			'9': {value: 'a numeric key', sourceFiles: []},
-			b: {value: 'another value', sourceFiles: []}
+			z: { value: 'last value', sourceFiles: [] },
+			a: { value: 'a value', sourceFiles: [] },
+			'9': { value: 'a numeric key', sourceFiles: [] },
+			b: { value: 'another value', sourceFiles: [] }
 		});
 		const extracted = new TranslationCollection();
 		const existing = new TranslationCollection();
 
+		// Assert all values are processed correctly
 		expect(processor.process(collection, extracted, existing).values).to.deep.equal({
-			'9': {value: 'a numeric key', sourceFiles: []},
-			a: {value: 'a value', sourceFiles: []},
-			b: {value: 'another value', sourceFiles: []},
-			z: {value: 'last value', sourceFiles: []}
+			'9': { value: 'a numeric key', sourceFiles: [] },
+			a: { value: 'a value', sourceFiles: [] },
+			b: { value: 'another value', sourceFiles: [] },
+			z: { value: 'last value', sourceFiles: [] }
 		});
+
+		// Assert all keys are in the correct order
+		expect(processor.process(collection, extracted, existing).keys()).toStrictEqual(['9', 'a', 'b', 'z']);
 	});
 
-	it('should perform case insensitive sorting', () => {
+	it('should perform variant sensitive sorting', () => {
 		const collection = new TranslationCollection({
 			c: { value: 'letter c', sourceFiles: [] },
 			j: { value: 'letter j', sourceFiles: [] },
 			b: { value: 'letter b', sourceFiles: [] },
 			a: { value: 'letter a', sourceFiles: [] },
+			à: { value: 'letter à', sourceFiles: [] },
 			h: { value: 'letter h', sourceFiles: [] },
 			B: { value: 'letter B', sourceFiles: [] },
 			H: { value: 'letter H', sourceFiles: [] },
@@ -47,9 +58,11 @@ describe('SortByKeyPostProcessor', () => {
 			g: { value: 'letter g', sourceFiles: [] }
 		});
 
+		// Assert all values are processed correctly
 		expect(processor.process(collection, new TranslationCollection(), new TranslationCollection()).values).to.deep.equal({
 			A: { value: 'letter A', sourceFiles: [] },
 			a: { value: 'letter a', sourceFiles: [] },
+			à: { value: 'letter à', sourceFiles: [] },
 			B: { value: 'letter B', sourceFiles: [] },
 			b: { value: 'letter b', sourceFiles: [] },
 			c: { value: 'letter c', sourceFiles: [] },
@@ -63,5 +76,376 @@ describe('SortByKeyPostProcessor', () => {
 			i: { value: 'letter i', sourceFiles: [] },
 			j: { value: 'letter j', sourceFiles: [] }
 		});
+
+		// Assert all keys are in the correct order
+		expect(processor.process(collection, new TranslationCollection(), new TranslationCollection()).keys()).toStrictEqual([
+			'A',
+			'B',
+			'C',
+			'H',
+			'a',
+			'b',
+			'c',
+			'd',
+			'e',
+			'f',
+			'g',
+			'h',
+			'i',
+			'j',
+			'à'
+		]);
+	});
+});
+
+describe('SortByKeyPostProcessor - base sensitivity should treat all base characters as equal', () => {
+	let processor: PostProcessorInterface;
+
+	beforeEach(() => {
+		processor = new SortByKeyPostProcessor('base');
+	});
+
+	it('should sort keys alphanumerically', () => {
+		const collection = new TranslationCollection({
+			z: { value: 'last value', sourceFiles: [] },
+			a: { value: 'a value', sourceFiles: [] },
+			'9': { value: 'a numeric key', sourceFiles: [] },
+			b: { value: 'another value', sourceFiles: [] }
+		});
+		const extracted = new TranslationCollection();
+		const existing = new TranslationCollection();
+
+		// Assert all values are processed correctly
+		expect(processor.process(collection, extracted, existing).values).to.deep.equal({
+			'9': { value: 'a numeric key', sourceFiles: [] },
+			a: { value: 'a value', sourceFiles: [] },
+			b: { value: 'another value', sourceFiles: [] },
+			z: { value: 'last value', sourceFiles: [] }
+		});
+
+		// Assert all keys are in the correct order
+		expect(processor.process(collection, extracted, existing).keys()).toStrictEqual(['9', 'a', 'b', 'z']);
+	});
+
+	it('should perform base sensitive sorting', () => {
+		const collection = new TranslationCollection({
+			c: { value: 'letter c', sourceFiles: [] },
+			j: { value: 'letter j', sourceFiles: [] },
+			b: { value: 'letter b', sourceFiles: [] },
+			a: { value: 'letter a', sourceFiles: [] },
+			à: { value: 'letter à', sourceFiles: [] },
+			h: { value: 'letter h', sourceFiles: [] },
+			B: { value: 'letter B', sourceFiles: [] },
+			H: { value: 'letter H', sourceFiles: [] },
+			i: { value: 'letter i', sourceFiles: [] },
+			C: { value: 'letter C', sourceFiles: [] },
+			e: { value: 'letter e', sourceFiles: [] },
+			f: { value: 'letter f', sourceFiles: [] },
+			d: { value: 'letter d', sourceFiles: [] },
+			A: { value: 'letter A', sourceFiles: [] },
+			g: { value: 'letter g', sourceFiles: [] }
+		});
+
+		// Assert all values are processed correctly
+		expect(processor.process(collection, new TranslationCollection(), new TranslationCollection()).values).to.deep.equal({
+			c: { value: 'letter c', sourceFiles: [] },
+			j: { value: 'letter j', sourceFiles: [] },
+			b: { value: 'letter b', sourceFiles: [] },
+			a: { value: 'letter a', sourceFiles: [] },
+			à: { value: 'letter à', sourceFiles: [] },
+			h: { value: 'letter h', sourceFiles: [] },
+			B: { value: 'letter B', sourceFiles: [] },
+			H: { value: 'letter H', sourceFiles: [] },
+			i: { value: 'letter i', sourceFiles: [] },
+			C: { value: 'letter C', sourceFiles: [] },
+			e: { value: 'letter e', sourceFiles: [] },
+			f: { value: 'letter f', sourceFiles: [] },
+			d: { value: 'letter d', sourceFiles: [] },
+			A: { value: 'letter A', sourceFiles: [] },
+			g: { value: 'letter g', sourceFiles: [] }
+		});
+
+		// Assert all keys are in the correct order
+		expect(processor.process(collection, new TranslationCollection(), new TranslationCollection()).keys()).toStrictEqual([
+			'a',
+			'à',
+			'A',
+			'b',
+			'B',
+			'c',
+			'C',
+			'd',
+			'e',
+			'f',
+			'g',
+			'h',
+			'H',
+			'i',
+			'j'
+		]);
+	});
+});
+
+describe('SortByKeyPostProcessor - accent sensitivity should sort treat lowercase and uppercase as equal but accents and diacretics as not equal', () => {
+	let processor: PostProcessorInterface;
+
+	beforeEach(() => {
+		processor = new SortByKeyPostProcessor('accent');
+	});
+
+	it('should sort keys alphanumerically', () => {
+		const collection = new TranslationCollection({
+			z: { value: 'last value', sourceFiles: [] },
+			a: { value: 'a value', sourceFiles: [] },
+			'9': { value: 'a numeric key', sourceFiles: [] },
+			b: { value: 'another value', sourceFiles: [] }
+		});
+		const extracted = new TranslationCollection();
+		const existing = new TranslationCollection();
+
+		// Assert all values are processed correctly
+		expect(processor.process(collection, extracted, existing).values).to.deep.equal({
+			'9': { value: 'a numeric key', sourceFiles: [] },
+			a: { value: 'a value', sourceFiles: [] },
+			b: { value: 'another value', sourceFiles: [] },
+			z: { value: 'last value', sourceFiles: [] }
+		});
+
+		// Assert all keys are in the correct order
+		expect(processor.process(collection, extracted, existing).keys()).toStrictEqual(['9', 'a', 'b', 'z']);
+	});
+
+	it('should perform accent sensitive sorting', () => {
+		const collection = new TranslationCollection({
+			c: { value: 'letter c', sourceFiles: [] },
+			j: { value: 'letter j', sourceFiles: [] },
+			b: { value: 'letter b', sourceFiles: [] },
+			a: { value: 'letter a', sourceFiles: [] },
+			à: { value: 'letter à', sourceFiles: [] },
+			h: { value: 'letter h', sourceFiles: [] },
+			B: { value: 'letter B', sourceFiles: [] },
+			H: { value: 'letter H', sourceFiles: [] },
+			i: { value: 'letter i', sourceFiles: [] },
+			C: { value: 'letter C', sourceFiles: [] },
+			e: { value: 'letter e', sourceFiles: [] },
+			f: { value: 'letter f', sourceFiles: [] },
+			d: { value: 'letter d', sourceFiles: [] },
+			A: { value: 'letter A', sourceFiles: [] },
+			g: { value: 'letter g', sourceFiles: [] }
+		});
+
+		// Assert all values are processed correctly
+		expect(processor.process(collection, new TranslationCollection(), new TranslationCollection()).values).to.deep.equal({
+			c: { value: 'letter c', sourceFiles: [] },
+			j: { value: 'letter j', sourceFiles: [] },
+			b: { value: 'letter b', sourceFiles: [] },
+			a: { value: 'letter a', sourceFiles: [] },
+			à: { value: 'letter à', sourceFiles: [] },
+			h: { value: 'letter h', sourceFiles: [] },
+			B: { value: 'letter B', sourceFiles: [] },
+			H: { value: 'letter H', sourceFiles: [] },
+			i: { value: 'letter i', sourceFiles: [] },
+			C: { value: 'letter C', sourceFiles: [] },
+			e: { value: 'letter e', sourceFiles: [] },
+			f: { value: 'letter f', sourceFiles: [] },
+			d: { value: 'letter d', sourceFiles: [] },
+			A: { value: 'letter A', sourceFiles: [] },
+			g: { value: 'letter g', sourceFiles: [] }
+		});
+
+		// Assert all keys are in the correct order
+		expect(processor.process(collection, new TranslationCollection(), new TranslationCollection()).keys()).toStrictEqual([
+			'a',
+			'A',
+			'à',
+			'b',
+			'B',
+			'c',
+			'C',
+			'd',
+			'e',
+			'f',
+			'g',
+			'h',
+			'H',
+			'i',
+			'j'
+		]);
+	});
+});
+
+describe('SortByKeyPostProcessor - case sensitivity should treat lowercase and uppercase as not equal but accents and diacretics as equal', () => {
+	let processor: PostProcessorInterface;
+
+	beforeEach(() => {
+		processor = new SortByKeyPostProcessor('case');
+	});
+
+	it('should sort keys alphanumerically', () => {
+		const collection = new TranslationCollection({
+			z: { value: 'last value', sourceFiles: [] },
+			a: { value: 'a value', sourceFiles: [] },
+			'9': { value: 'a numeric key', sourceFiles: [] },
+			b: { value: 'another value', sourceFiles: [] }
+		});
+		const extracted = new TranslationCollection();
+		const existing = new TranslationCollection();
+
+		// Assert all values are processed correctly
+		expect(processor.process(collection, extracted, existing).values).to.deep.equal({
+			'9': { value: 'a numeric key', sourceFiles: [] },
+			a: { value: 'a value', sourceFiles: [] },
+			b: { value: 'another value', sourceFiles: [] },
+			z: { value: 'last value', sourceFiles: [] }
+		});
+
+		// Assert all keys are in the correct order
+		expect(processor.process(collection, extracted, existing).keys()).toStrictEqual(['9', 'a', 'b', 'z']);
+	});
+
+	it('should perform case sensitive sorting', () => {
+		const collection = new TranslationCollection({
+			c: { value: 'letter c', sourceFiles: [] },
+			j: { value: 'letter j', sourceFiles: [] },
+			b: { value: 'letter b', sourceFiles: [] },
+			a: { value: 'letter a', sourceFiles: [] },
+			à: { value: 'letter à', sourceFiles: [] },
+			h: { value: 'letter h', sourceFiles: [] },
+			B: { value: 'letter B', sourceFiles: [] },
+			H: { value: 'letter H', sourceFiles: [] },
+			i: { value: 'letter i', sourceFiles: [] },
+			C: { value: 'letter C', sourceFiles: [] },
+			e: { value: 'letter e', sourceFiles: [] },
+			f: { value: 'letter f', sourceFiles: [] },
+			d: { value: 'letter d', sourceFiles: [] },
+			A: { value: 'letter A', sourceFiles: [] },
+			g: { value: 'letter g', sourceFiles: [] }
+		});
+
+		// Assert all values are processed correctly
+		expect(processor.process(collection, new TranslationCollection(), new TranslationCollection()).values).to.deep.equal({
+			c: { value: 'letter c', sourceFiles: [] },
+			j: { value: 'letter j', sourceFiles: [] },
+			b: { value: 'letter b', sourceFiles: [] },
+			a: { value: 'letter a', sourceFiles: [] },
+			à: { value: 'letter à', sourceFiles: [] },
+			h: { value: 'letter h', sourceFiles: [] },
+			B: { value: 'letter B', sourceFiles: [] },
+			H: { value: 'letter H', sourceFiles: [] },
+			i: { value: 'letter i', sourceFiles: [] },
+			C: { value: 'letter C', sourceFiles: [] },
+			e: { value: 'letter e', sourceFiles: [] },
+			f: { value: 'letter f', sourceFiles: [] },
+			d: { value: 'letter d', sourceFiles: [] },
+			A: { value: 'letter A', sourceFiles: [] },
+			g: { value: 'letter g', sourceFiles: [] }
+		});
+
+		// Assert all keys are in the correct order
+		expect(processor.process(collection, new TranslationCollection(), new TranslationCollection()).keys()).toStrictEqual([
+			'a',
+			'à',
+			'A',
+			'b',
+			'B',
+			'c',
+			'C',
+			'd',
+			'e',
+			'f',
+			'g',
+			'h',
+			'H',
+			'i',
+			'j'
+		]);
+	});
+});
+
+describe('SortByKeyPostProcessor - variant sensitivity should treat lowercase, uppercase, accents and diacretics as not equal', () => {
+	let processor: PostProcessorInterface;
+
+	beforeEach(() => {
+		processor = new SortByKeyPostProcessor('variant');
+	});
+
+	it('should sort keys alphanumerically', () => {
+		const collection = new TranslationCollection({
+			z: { value: 'last value', sourceFiles: [] },
+			a: { value: 'a value', sourceFiles: [] },
+			'9': { value: 'a numeric key', sourceFiles: [] },
+			b: { value: 'another value', sourceFiles: [] }
+		});
+		const extracted = new TranslationCollection();
+		const existing = new TranslationCollection();
+
+		// Assert all values are processed correctly
+		expect(processor.process(collection, extracted, existing).values).to.deep.equal({
+			'9': { value: 'a numeric key', sourceFiles: [] },
+			a: { value: 'a value', sourceFiles: [] },
+			b: { value: 'another value', sourceFiles: [] },
+			z: { value: 'last value', sourceFiles: [] }
+		});
+
+		// Assert all keys are in the correct order
+		expect(processor.process(collection, extracted, existing).keys()).toStrictEqual(['9', 'a', 'b', 'z']);
+	});
+
+	it('should perform variant sensitive sorting', () => {
+		const collection = new TranslationCollection({
+			c: { value: 'letter c', sourceFiles: [] },
+			j: { value: 'letter j', sourceFiles: [] },
+			b: { value: 'letter b', sourceFiles: [] },
+			a: { value: 'letter a', sourceFiles: [] },
+			à: { value: 'letter à', sourceFiles: [] },
+			h: { value: 'letter h', sourceFiles: [] },
+			B: { value: 'letter B', sourceFiles: [] },
+			H: { value: 'letter H', sourceFiles: [] },
+			i: { value: 'letter i', sourceFiles: [] },
+			C: { value: 'letter C', sourceFiles: [] },
+			e: { value: 'letter e', sourceFiles: [] },
+			f: { value: 'letter f', sourceFiles: [] },
+			d: { value: 'letter d', sourceFiles: [] },
+			A: { value: 'letter A', sourceFiles: [] },
+			g: { value: 'letter g', sourceFiles: [] }
+		});
+
+		// Assert all values are processed correctly
+		expect(processor.process(collection, new TranslationCollection(), new TranslationCollection()).values).to.deep.equal({
+			c: { value: 'letter c', sourceFiles: [] },
+			j: { value: 'letter j', sourceFiles: [] },
+			b: { value: 'letter b', sourceFiles: [] },
+			a: { value: 'letter a', sourceFiles: [] },
+			à: { value: 'letter à', sourceFiles: [] },
+			h: { value: 'letter h', sourceFiles: [] },
+			B: { value: 'letter B', sourceFiles: [] },
+			H: { value: 'letter H', sourceFiles: [] },
+			i: { value: 'letter i', sourceFiles: [] },
+			C: { value: 'letter C', sourceFiles: [] },
+			e: { value: 'letter e', sourceFiles: [] },
+			f: { value: 'letter f', sourceFiles: [] },
+			d: { value: 'letter d', sourceFiles: [] },
+			A: { value: 'letter A', sourceFiles: [] },
+			g: { value: 'letter g', sourceFiles: [] }
+		});
+
+		// Assert all keys are in the correct order
+		expect(processor.process(collection, new TranslationCollection(), new TranslationCollection()).keys()).toStrictEqual([
+			'a',
+			'A',
+			'à',
+			'b',
+			'B',
+			'c',
+			'C',
+			'd',
+			'e',
+			'f',
+			'g',
+			'h',
+			'H',
+			'i',
+			'j'
+		]);
 	});
 });
diff --git a/tests/utils/ast-helpers.spec.ts b/tests/utils/ast-helpers.spec.ts
new file mode 100644
index 0000000..053214a
--- /dev/null
+++ b/tests/utils/ast-helpers.spec.ts
@@ -0,0 +1,73 @@
+import { ScriptKind, tsquery } from '@phenomnomnominal/tsquery';
+import { beforeEach, describe, it, expect, vi } from 'vitest';
+import { LanguageVariant } from 'typescript';
+
+import { getAST } from '../../src/utils/ast-helpers';
+
+describe('getAST()', () => {
+	const tsqueryAstSpy = vi.spyOn(tsquery, 'ast');
+
+	beforeEach(() => {
+		tsqueryAstSpy.mockClear();
+	});
+
+	it('should return the AST for a TypeScript source with a .ts file extension', () => {
+		const source = 'const x: number = 42;';
+		const fileName = 'example.ts';
+
+		const result = getAST(source, fileName);
+
+		expect(tsqueryAstSpy).toHaveBeenCalledWith(source, fileName, ScriptKind.TS);
+		expect(result.languageVariant).toBe(LanguageVariant.Standard);
+	});
+
+	it('should return the AST for a TypeScript source with a .tsx file extension', () => {
+		const source = 'const x: number = 42;';
+		const fileName = 'example.tsx';
+
+		const result = getAST(source, fileName);
+
+		expect(tsqueryAstSpy).toHaveBeenCalledWith(source, fileName, ScriptKind.TSX);
+		expect(result.languageVariant).toBe(LanguageVariant.JSX);
+	});
+
+	it('should return the AST for a JavaScript source with a .js file extension', () => {
+		const source = 'const x = 42;';
+		const fileName = 'example.js';
+
+		const result = getAST(source, fileName);
+
+		expect(tsqueryAstSpy).toHaveBeenCalledWith(source, fileName, ScriptKind.JS);
+		// JS files also return JSX language variant.
+		expect(result.languageVariant).toBe(LanguageVariant.JSX);
+	});
+
+	it('should return the AST for a JavaScript source with a .jsx file extension', () => {
+		const source = 'const x = 42;';
+		const fileName = 'example.jsx';
+
+		const result = getAST(source, fileName);
+
+		expect(tsqueryAstSpy).toHaveBeenCalledWith(source, fileName, ScriptKind.JSX);
+		expect(result.languageVariant).toBe(LanguageVariant.JSX);
+	});
+
+	it('should use ScriptKind.TS if the file extension is unsupported', () => {
+		const source = 'const x: number = 42;';
+		const fileName = 'example.unknown';
+
+		const result = getAST(source, fileName);
+
+		expect(tsqueryAstSpy).toHaveBeenCalledWith(source, fileName, ScriptKind.TS);
+		expect(result.languageVariant).toBe(LanguageVariant.Standard);
+	});
+
+	it('should use ScriptKind.TS if no file name is provided', () => {
+		const source = 'const x: number = 42;';
+
+		const result = getAST(source);
+
+		expect(tsqueryAstSpy).toHaveBeenCalledWith(source, '', ScriptKind.TS);
+		expect(result.languageVariant).toBe(LanguageVariant.Standard);
+	});
+});