core_orphan-finder.js

'use strict';

/**
 * @fileoverview Unused translation key detector.
 * Scans source files to identify orphan keys not referenced in code.
 * @module orphan-finder
 */

const path = require('path');
const fs = require('./fs-adapter');
const { readJsonFile, flattenKeys, normalizeData } = require('./json-utils');
const { collectFiles } = require('./file-walker');
const { isTranslationFile, readI18nDirectory } = require('./sync-checker');
const { logListWithLimit } = require('./log-utils');

const DYNAMIC_KEY_RE = /(?:translate|transloco)\s*\(\s*[`'"]([^`'"]*)\$\{/g;
const CONCAT_KEY_RE = /['"`]([^'"`]+)['"]\s*\+\s*(?:\w+|['"`])/g;

async function getLangFiles(i18nDir) {
  const dirEntries = await readI18nDirectory(i18nDir);
  return dirEntries.filter(isTranslationFile).map(e => e.name);
}

async function loadRefData(i18nDir, refFile, format) {
  const refData = await readJsonFile(path.join(i18nDir, refFile));
  if (!refData) {
    throw new Error(`Cannot parse ${refFile}`);
  }
  return { refFile, allKeys: flattenKeys(normalizeData(refData, format)) };
}

async function loadReferenceKeysForOrphans(ctx) {
  const { i18nDir, format, log } = ctx;
  const langFiles = await getLangFiles(i18nDir);
  if (langFiles.length === 0) {
    log('No language files found.');
    return null;
  }
  return loadRefData(i18nDir, langFiles[0], format);
}

function detectDynamicPatterns(content, relPath, dynamicPatterns) {
  for (const match of content.matchAll(DYNAMIC_KEY_RE)) {
    dynamicPatterns.push({ file: relPath, pattern: `${match[1]}...` });
  }
  for (const match of content.matchAll(CONCAT_KEY_RE)) {
    if (match[1].includes('.') && !match[1].includes(' ')) {
      dynamicPatterns.push({ file: relPath, pattern: `${match[1]}+...` });
    }
  }
}

const QUOTED_STRING_RE = /['"`]([a-zA-Z][a-zA-Z0-9_.]+)['"`]/g;

function scanFileForKeyUsage(ctx) {
  const { content, relPath, keySet, keyUsageMap, dynamicPatterns } = ctx;
  detectDynamicPatterns(content, relPath, dynamicPatterns);

  for (const match of content.matchAll(QUOTED_STRING_RE)) {
    const candidate = match[1];
    if (keySet.has(candidate)) {
      keyUsageMap.set(candidate, true);
    }
  }
}

async function scanAllFilesForKeys(files, ctx) {
  const { srcDir, keySet, keyUsageMap, dynamicPatterns, verbose } = ctx;
  await Promise.all(
    files.map(async filePath => {
      try {
        const content = await fs.readFile(filePath, 'utf-8');
        scanFileForKeyUsage({
          content,
          relPath: path.relative(srcDir, filePath),
          keySet,
          keyUsageMap,
          dynamicPatterns,
        });
      } catch (err) {
        if (verbose) {
          console.warn(`Warning: Cannot read ${path.relative(srcDir, filePath)}: ${err.message}`);
        }
      }
    }),
  );
}

function partitionKeyUsage(keyUsageMap) {
  const usedKeys = [];
  const orphanKeys = [];
  for (const [key, used] of keyUsageMap) {
    (used ? usedKeys : orphanKeys).push(key);
  }
  return { usedKeys, orphanKeys };
}

function logDynamicPatterns(dynamicPatterns, log) {
  if (dynamicPatterns.length === 0) {
    return;
  }
  const uniquePatterns = [...new Set(dynamicPatterns.map(p => p.pattern))];
  logListWithLimit({
    items: uniquePatterns,
    label: `Dynamic key patterns detected`,
    limit: 5,
    log,
    prefix: '  [!] ',
  });
  log('  (Some orphans may be false positives due to dynamic keys)\n');
}

function logOrphanResults(results, log) {
  const { dynamicPatterns, orphanKeys, allKeys, usedKeys } = results;
  logDynamicPatterns(dynamicPatterns, log);

  if (orphanKeys.length > 0) {
    logListWithLimit({ items: orphanKeys, label: 'Orphan keys', limit: 20, log });
  } else {
    log('No orphan keys found.');
  }

  log(`\nSummary\n${'-'.repeat(50)}`);
  log(`Total keys:      ${allKeys.length}\nUsed keys:       ${usedKeys.length}`);
  log(`Orphan keys:     ${orphanKeys.length}\nDynamic patterns: ${dynamicPatterns.length}`);
}

async function scanAndCollect(ctx) {
  const { srcDir, excludedFolders, allKeys, verbose } = ctx;
  const keySet = new Set(allKeys);
  const keyUsageMap = new Map(allKeys.map(k => [k, false]));
  const dynamicPatterns = [];
  const files = await collectFiles(srcDir, excludedFolders);
  await scanAllFilesForKeys(files, { srcDir, keySet, keyUsageMap, dynamicPatterns, verbose });
  const { usedKeys, orphanKeys } = partitionKeyUsage(keyUsageMap);
  return { usedKeys, orphanKeys, dynamicPatterns, allKeys };
}

function buildOrphanResult(ctx) {
  const { results, strict, exitCodes, log } = ctx;
  const hasOrphans = strict && results.orphanKeys.length > 0;
  if (hasOrphans) {
    log('\nOrphan check failed (--strict mode)');
  }
  return {
    success: !hasOrphans,
    exitCode: hasOrphans ? exitCodes.untranslated : exitCodes.success,
    ...results,
  };
}

function logOrphanHeader(log) {
  log('Transloco Find Orphan Keys');
  log('='.repeat(50));
}

function getOrphanExitCodes(options) {
  return options.exitCodes || { success: 0, untranslated: 1 };
}

function handleNoRef(exitCodes) {
  return { success: true, exitCode: exitCodes.success, orphanKeys: [] };
}

async function executeOrphanScan(ctx) {
  const { srcDir, excludedFolders, allKeys, verbose, refFile, log, strict, exitCodes } = ctx;
  log(`Reference: ${refFile} (${allKeys.length} keys)\nScanning: ${srcDir}\n`);
  const results = await scanAndCollect({ srcDir, excludedFolders, allKeys, verbose });
  logOrphanResults(results, log);
  return buildOrphanResult({ results, strict, exitCodes, log });
}

function getOrphanDefaults() {
  return { format: 'nested', excludedFolders: [], verbose: false, strict: false, log: console.log };
}

function parseOrphanOptions(options) {
  const defaults = getOrphanDefaults();
  return { ...defaults, ...options, exitCodes: getOrphanExitCodes(options) };
}

/**
 * Scans source files to find translation keys not used in code
 * @param {OrphanOptions} [options]
 * @returns {Promise<OrphanResult>}
 * @example
 * const { orphanKeys } = await findOrphans({ i18nDir: './src/i18n', srcDir: './src/app' });
 */
async function findOrphans(options = {}) {
  const opts = parseOrphanOptions(options);
  logOrphanHeader(opts.log);
  const refResult = await loadReferenceKeysForOrphans({
    i18nDir: opts.i18nDir,
    format: opts.format,
    log: opts.log,
  });
  if (!refResult) {
    return handleNoRef(opts.exitCodes);
  }
  return executeOrphanScan({ ...refResult, ...opts });
}

module.exports = {
  findOrphans,
};