All files / PARSE/1-Discover PathResolver.ts

100% Statements 34/34
100% Branches 30/30
100% Functions 7/7
100% Lines 34/34

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 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222                                                                                        793x 793x                       694x 694x     694x 2x     692x     692x 641x       53x                 199x                               199x 199x   190x 190x         9x 9x                                 149x                                       136x 136x         136x 13x         123x                   285x   285x 285x   253x                                       32x 10x 10x     10x 9x             1x               22x                
/**
 * PathResolver
 * Handles path calculations for output files.
 *
 * Consolidates path resolution logic used by Transpiler and CleanCommand,
 * including directory structure preservation.
 */
 
import { join, basename, relative, dirname, resolve, sep } from "node:path";
 
import IDiscoveredFile from "./types/IDiscoveredFile";
import type TSourceExtension from "../../types/TSourceExtension";
import type THeaderExtension from "../../types/THeaderExtension";
import IFileSystem from "../../types/IFileSystem";
 
/**
 * Configuration for PathResolver
 */
interface IPathResolverConfig {
  /** Input files or directories */
  inputs: string[];
  /** Output directory for generated code */
  outDir: string;
  /** Optional separate output directory for headers */
  headerOutDir?: string;
  /**
   * Issue #1547: the base that a discovered file's path is made relative to
   * when it falls outside every input directory. Derived by
   * `Transpiler.determineProjectRoot()` -- the nearest directory holding a
   * project marker, `cnext.config.json` among them, so it lands on the
   * directory cosmiconfig found -- never read from the process. Absent means "no stable base", and the header is placed by
   * basename rather than against a base that would vary per run.
   */
  projectRoot?: string;
}
 
/**
 * Resolves output paths for transpiled files
 */
class PathResolver {
  private readonly config: IPathResolverConfig;
  private readonly fs: IFileSystem;
 
  constructor(config: IPathResolverConfig, fs: IFileSystem) {
    this.config = config;
    this.fs = fs;
  }
 
  /**
   * Get relative path from any input directory for a file.
   * Returns the relative path (e.g., "Display/Utils.cnx") or null if the file
   * is not under any input directory.
   *
   * This is the core utility used by getSourceRelativePath, getOutputPath,
   * and getHeaderOutputPath for directory structure preservation.
   */
  getRelativePathFromInputs(filePath: string): string | null {
    for (const input of this.config.inputs) {
      const resolvedInput = resolve(input);
 
      // Skip if input is a file (not a directory) - can't preserve structure
      if (this.fs.exists(resolvedInput) && this.fs.isFile(resolvedInput)) {
        continue;
      }
 
      const relativePath = relative(resolvedInput, filePath);
 
      // Check if file is under this input directory
      if (relativePath && !relativePath.startsWith("..")) {
        return relativePath;
      }
    }
 
    return null;
  }
 
  /**
   * Issue #339: Get relative path from input directory for self-include generation.
   * Returns the relative path (e.g., "Display/Utils.cnx") or just the basename
   * if the file is not in any input directory.
   */
  getSourceRelativePath(filePath: string): string {
    return this.getRelativePathFromInputs(filePath) ?? basename(filePath);
  }
 
  /**
   * Get output path for a transpiled file (.c or .cpp)
   *
   * Issue #1319: takes the run's source extension rather than its mode. Naming
   * an output file is a decision, and `data/` is the earliest layer -- it is
   * told the answer rather than deriving one, which is what let the mode-to-
   * extension decision collapse to a single owner.
   *
   * @param file - The discovered file to get output path for
   * @param ext - The run's source extension (".c" or ".cpp")
   * @returns The full output path
   */
  getOutputPath(file: IDiscoveredFile, ext: TSourceExtension): string {
    const relativePath = this.getRelativePathFromInputs(file.path);
    if (relativePath) {
      // File is under an input directory - preserve structure
      const outputRelative = relativePath.replace(/\.cnx$|\.cnext$/, ext);
      return join(this.config.outDir, outputRelative);
    }
 
    // Fallback: output next to the source file (not in outDir)
    // This handles included files that aren't under any input directory
    const outputName = basename(file.path).replace(/\.cnx$|\.cnext$/, ext);
    return join(dirname(file.path), outputName);
  }
 
  /**
   * Get output path for a header file (.h or .hpp)
   * Uses headerOutDir if specified, otherwise falls back to outDir
   *
   * Issue #1319: takes the run's header extension rather than its mode. This
   * parameter previously defaulted to `false`, so a caller that forgot it got
   * `.h` in a C++ run with nothing reporting the mismatch -- while the sibling
   * `getOutputPath` required the same fact.
   *
   * @param file - The discovered file to get header path for
   * @param ext - The run's header extension (".h" or ".hpp", Issue #933)
   * @returns The full header output path
   */
  getHeaderOutputPath(file: IDiscoveredFile, ext: THeaderExtension): string {
    return this.headerPathFor(file.path, ext);
  }
 
  /**
   * Issue #1467: the path an `#include` must name to reach the header this
   * resolver will write for `cnxPath`, relative to the header output root.
   *
   * This is the single answer to "which header does this include resolve to?".
   * It is DERIVED from `headerPathFor` -- the same calculation that decides
   * where the header is written -- because those two facts were computed
   * independently before, and a derivation that merely agrees is a latent
   * divergence. Nothing downstream may re-derive it from the author's
   * spelling: a bare `<utils.cnx>` and a nested header are both legal, and
   * only this method knows they go together.
   *
   * Returns null when the header lands outside the header output root, where
   * no path relative to that root can name it and `-I <header-out>` cannot
   * work regardless. The caller keeps the author's spelling there.
   */
  getHeaderIncludePath(cnxPath: string, ext: THeaderExtension): string | null {
    const headerDir = this.config.headerOutDir || this.config.outDir;
    const includePath = relative(
      resolve(headerDir),
      resolve(this.headerPathFor(cnxPath, ext)),
    );
 
    if (!includePath || includePath.startsWith("..")) {
      return null;
    }
 
    // POSIX separators: this becomes the text inside `#include <...>`, which
    // is not a filesystem path in the generated C.
    return includePath.split(sep).join("/");
  }
 
  /**
   * Issue #1467: where the header for `cnxPath` goes. Pure -- it creates no
   * directories, so `getHeaderIncludePath` can ask the question without the
   * side effect that answering it used to carry.
   */
  private headerPathFor(cnxPath: string, ext: THeaderExtension): string {
    // Use headerOutDir if specified, otherwise fall back to outDir
    const headerDir = this.config.headerOutDir || this.config.outDir;
 
    const relativePath = this.getRelativePathFromInputs(cnxPath);
    if (relativePath) {
      // File is under an input directory - preserve structure
      return join(headerDir, relativePath.replace(/\.cnx$|\.cnext$/, ext));
    }
 
    // Issue #489: a file outside every input directory still has to land
    // somewhere under an explicit headerOutDir, keeping its directory structure.
    //
    // Issue #1547: that structure is measured from the PROJECT ROOT, not from
    // `process.cwd()`. Anchoring headerOutDir alone fixed only the header root;
    // the subpath built inside it was still derived from wherever the shell
    // happened to be, so a sideways or upward `#include` -- any file not under
    // the entry's own directory -- wrote its header to a different place per
    // run, and `getHeaderIncludePath` derives from this method, so the
    // `#include` emitted into the generated .c moved with it. Two runs of the
    // same project differed only by CWD and both exited 0.
    //
    // With no project root the CWD remains the base, and that is not a
    // leftover: no project root means no config file was found, so an explicit
    // headerOutDir can only have come from `--header-out` -- a path typed at
    // the shell, which the CWD is the correct base for. Issue #1467 pins that
    // layout (a shared tree inside the CWD nests its header).
    if (this.config.headerOutDir) {
      const base = this.config.projectRoot ?? process.cwd();
      const relativeFromBase = relative(resolve(base), resolve(cnxPath));
 
      // Only keep the structure when the file really is under the base.
      if (relativeFromBase && !relativeFromBase.startsWith("..")) {
        return join(
          this.config.headerOutDir,
          relativeFromBase.replace(/\.cnx$|\.cnext$/, ext),
        );
      }
 
      // File outside the base: place by basename.
      return join(
        this.config.headerOutDir,
        basename(cnxPath).replace(/\.cnx$|\.cnext$/, ext),
      );
    }
 
    // Fallback: output next to the source file (no headerDir specified)
    // This handles included files that aren't under any input directory
    return join(
      dirname(cnxPath),
      basename(cnxPath).replace(/\.cnx$|\.cnext$/, ext),
    );
  }
}
 
export default PathResolver;