findCaseMismatch.js 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. /*
  2. MIT License http://www.opensource.org/licenses/mit-license.php
  3. Author Alexander Akait @alexander-akait
  4. */
  5. "use strict";
  6. const { dirname, join } = require("./fs");
  7. /** @import { InputFileSystem } from "./fs" */
  8. /** @typedef {[string, string]} CaseCorrection wrongly cased segment paired with its real name */
  9. /** @typedef {{ corrections: CaseCorrection[], path: string }} CaseMismatch */
  10. /** Bounds the walk for a path pointing far outside any existing directory. */
  11. const MAX_MISSING_SEGMENTS = 20;
  12. /**
  13. * @param {InputFileSystem} fs input file system
  14. * @param {string} absolutePath absolute path
  15. * @returns {string} the last segment of the path, or an empty string at the root
  16. */
  17. const basename = (fs, absolutePath) => {
  18. const parent = dirname(fs, absolutePath);
  19. if (parent === absolutePath) return "";
  20. let start = parent.length;
  21. const charCode = absolutePath.charCodeAt(start);
  22. if (charCode === 47 /* "/" */ || charCode === 92 /* "\\" */) start++;
  23. return absolutePath.slice(start);
  24. };
  25. /**
  26. * Finds an existing path that differs from `missingPath` only in the casing of
  27. * its segments, by listing the directories the path walks through.
  28. * @param {InputFileSystem} fs input file system
  29. * @param {string} missingPath absolute path that does not exist
  30. * @param {(mismatch?: CaseMismatch) => void} callback receives the real path and the wrongly cased segments, outermost first, or nothing when the path does not exist under any casing
  31. * @returns {void}
  32. */
  33. const findCaseMismatch = (fs, missingPath, callback) => {
  34. /** @type {string[]} */
  35. const segments = [];
  36. /** @type {CaseCorrection[]} */
  37. const corrections = [];
  38. /**
  39. * @param {string} directory existing directory the remaining segments start from
  40. * @param {string[]} entries its entries
  41. * @param {number} index index into `segments`
  42. * @returns {void}
  43. */
  44. const walkDown = (directory, entries, index) => {
  45. const name = segments[index];
  46. let realName = name;
  47. if (!entries.includes(name)) {
  48. const lowerCasedName = name.toLowerCase();
  49. const matches = entries.filter((e) => e.toLowerCase() === lowerCasedName);
  50. // More than one match means the real name is not knowable from the request alone
  51. if (matches.length !== 1) return callback();
  52. realName = matches[0];
  53. corrections.push([name, realName]);
  54. }
  55. const next = join(fs, directory, realName);
  56. if (index === segments.length - 1) {
  57. return callback(
  58. corrections.length > 0 ? { corrections, path: next } : undefined
  59. );
  60. }
  61. fs.readdir(next, (err, nextEntries) => {
  62. if (err || !nextEntries) return callback();
  63. walkDown(next, /** @type {string[]} */ (nextEntries), index + 1);
  64. });
  65. };
  66. let current = missingPath;
  67. /**
  68. * @returns {void}
  69. */
  70. const walkUp = () => {
  71. const parent = dirname(fs, current);
  72. const name = basename(fs, current);
  73. if (parent === current || name === "") return callback();
  74. segments.push(name);
  75. if (segments.length > MAX_MISSING_SEGMENTS) return callback();
  76. fs.readdir(parent, (err, entries) => {
  77. if (err || !entries) {
  78. current = parent;
  79. return walkUp();
  80. }
  81. segments.reverse();
  82. walkDown(parent, /** @type {string[]} */ (entries), 0);
  83. });
  84. };
  85. walkUp();
  86. };
  87. /**
  88. * Rewrites a request with the real casing found on disk. Corrections are
  89. * case-only, so each one keeps the length and position of what it replaces —
  90. * a request that omitted the extension spells a prefix of the segment.
  91. * @param {string} request request as written
  92. * @param {CaseCorrection[]} corrections corrections, outermost first
  93. * @returns {string | undefined} the corrected request, or undefined when a correction is not spelled out in the request
  94. */
  95. const applyCaseCorrections = (request, corrections) => {
  96. let result = request;
  97. let cursor = 0;
  98. for (const [wrongName, realName] of corrections) {
  99. let index = result.indexOf(wrongName, cursor);
  100. let length = wrongName.length;
  101. if (index === -1) {
  102. length = 0;
  103. for (let i = wrongName.length - 1; i > 0; i--) {
  104. // A prefix that is already correctly cased identifies nothing — the
  105. // request would come back unchanged and the hint would repeat it
  106. if (
  107. result.endsWith(wrongName.slice(0, i)) &&
  108. realName.slice(0, i) !== wrongName.slice(0, i)
  109. ) {
  110. index = result.length - i;
  111. length = i;
  112. break;
  113. }
  114. }
  115. if (length === 0) return undefined;
  116. }
  117. result =
  118. result.slice(0, index) +
  119. realName.slice(0, length) +
  120. result.slice(index + length);
  121. cursor = index + length;
  122. }
  123. return result;
  124. };
  125. module.exports.applyCaseCorrections = applyCaseCorrections;
  126. module.exports.findCaseMismatch = findCaseMismatch;