All files / cli PathNormalizer.ts

95.83% Statements 46/48
92.5% Branches 37/40
100% Functions 6/6
95.83% Lines 46/48

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171                        3x                   30x 17x     13x 30x 1x     12x 1x     11x 11x                             25x 25x   25x 3x     22x 1x     21x 13x       8x 8x 8x                           23x 23x     23x   23x 23x 16x 16x   16x 16x 15x 15x                         12x 7x   5x                           14x 14x   14x 18x 18x 18x 25x 21x 21x         14x                           7x                              
/**
 * PathNormalizer
 * Centralized path normalization for all config paths.
 * Handles tilde expansion and recursive directory search.
 */
 
import { join } from "node:path";
import IFileSystem from "../transpiler/types/IFileSystem";
import NodeFileSystem from "../transpiler/NodeFileSystem";
import ICliConfig from "./types/ICliConfig";
 
/** Default file system instance */
const defaultFs = NodeFileSystem.instance;
 
class PathNormalizer {
  /**
   * Expand ~ at the start of a path to the home directory.
   * Only expands leading tilde (~/path or bare ~).
   * @param path - Path that may start with ~
   * @returns Path with ~ expanded to home directory
   */
  static expandTilde(path: string): string {
    if (!path.startsWith("~")) {
      return path;
    }
 
    const home = process.env.HOME || process.env.USERPROFILE;
    if (!home) {
      return path;
    }
 
    if (path === "~") {
      return home;
    }
 
    Eif (path.startsWith("~/")) {
      return home + path.slice(1);
    }
 
    return path;
  }
 
  /**
   * Expand path/** to include all subdirectories recursively.
   * If path doesn't end with /**, returns the path as single-element array
   * (if it exists) or empty array (if it doesn't exist).
   * @param path - Path that may end with /**
   * @param fs - File system abstraction for testing
   * @returns Array of all directories found
   */
  static expandRecursive(path: string, fs: IFileSystem = defaultFs): string[] {
    const hasRecursiveSuffix = path.endsWith("/**");
    const basePath = hasRecursiveSuffix ? path.slice(0, -3) : path;
 
    if (!fs.exists(basePath)) {
      return [];
    }
 
    if (!fs.isDirectory(basePath)) {
      return [basePath];
    }
 
    if (!hasRecursiveSuffix) {
      return [basePath];
    }
 
    // Recursively collect all subdirectories
    const dirs: string[] = [basePath];
    this.collectSubdirectories(basePath, dirs, fs);
    return dirs;
  }
 
  /**
   * Recursively collect all subdirectories into the dirs array.
   * Uses a visited set to prevent symlink loops.
   */
  private static collectSubdirectories(
    dir: string,
    dirs: string[],
    fs: IFileSystem,
    visited: Set<string> = new Set(),
  ): void {
    // Get real path to detect symlink loops
    const realPath = fs.realpath ? fs.realpath(dir) : dir;
    Iif (visited.has(realPath)) {
      return; // Skip already-visited directories (symlink loop protection)
    }
    visited.add(realPath);
 
    const entries = fs.readdir(dir);
    for (const entry of entries) {
      const fullPath = join(dir, entry);
      Eif (fs.isDirectory(fullPath)) {
        // Check if this subdirectory's real path was already visited
        const subRealPath = fs.realpath ? fs.realpath(fullPath) : fullPath;
        if (!visited.has(subRealPath)) {
          dirs.push(fullPath);
          this.collectSubdirectories(fullPath, dirs, fs, visited);
        }
      }
    }
  }
 
  /**
   * Normalize a single path (tilde expansion only).
   * Used for output, headerOut, basePath.
   * @param path - Path to normalize
   * @returns Normalized path
   */
  static normalizePath(path: string): string {
    if (!path) {
      return path;
    }
    return this.expandTilde(path);
  }
 
  /**
   * Normalize include paths (tilde + recursive expansion).
   * Deduplicates paths to avoid redundant includes.
   * @param paths - Array of paths to normalize
   * @param fs - File system abstraction for testing
   * @returns Flattened array of all resolved directories (deduplicated)
   */
  static normalizeIncludePaths(
    paths: string[],
    fs: IFileSystem = defaultFs,
  ): string[] {
    const seen = new Set<string>();
    const result: string[] = [];
 
    for (const path of paths) {
      const expanded = this.expandTilde(path);
      const dirs = this.expandRecursive(expanded, fs);
      for (const dir of dirs) {
        if (!seen.has(dir)) {
          seen.add(dir);
          result.push(dir);
        }
      }
    }
 
    return result;
  }
 
  /**
   * Normalize all paths in a CLI config.
   * Single entry point for all path normalization.
   * @param config - CLI config with potentially unnormalized paths
   * @param fs - File system abstraction for testing
   * @returns New config with all paths normalized
   */
  static normalizeConfig(
    config: ICliConfig,
    fs: IFileSystem = defaultFs,
  ): ICliConfig {
    return {
      ...config,
      outputPath: this.normalizePath(config.outputPath),
      headerOutDir: config.headerOutDir
        ? this.normalizePath(config.headerOutDir)
        : undefined,
      basePath: config.basePath
        ? this.normalizePath(config.basePath)
        : undefined,
      includeDirs: this.normalizeIncludePaths(config.includeDirs, fs),
    };
  }
}
 
export default PathNormalizer;