cargo-workspace.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. /**
  2. * Cargo Workspace Resolver Helper
  3. *
  4. * Parses a project's root Cargo.toml and member crate manifests to
  5. * build a crate-name -> member-directory map. Used by the Rust
  6. * resolver to resolve `use crate_name::...` references that point
  7. * into workspace member crates.
  8. */
  9. import picomatch from 'picomatch';
  10. import { ResolutionContext } from '../types';
  11. const GLOB_CHARS = /[*?[\]{}!]/;
  12. const SKIP_DIRS = new Set(['target', 'node_modules', '.git', 'dist', 'build']);
  13. const MAX_GLOB_WALK_DEPTH = 5;
  14. function getSection(content: string, sectionName: string): string | null {
  15. const lines = content.split('\n');
  16. let inSection = false;
  17. const sectionLines: string[] = [];
  18. for (const line of lines) {
  19. const trimmed = line.trim();
  20. if (!inSection) {
  21. if (trimmed === `[${sectionName}]`) {
  22. inSection = true;
  23. }
  24. continue;
  25. }
  26. if (/^\[[^\]]+\]$/.test(trimmed)) {
  27. break;
  28. }
  29. sectionLines.push(line);
  30. }
  31. if (!inSection) return null;
  32. return sectionLines.join('\n');
  33. }
  34. function extractQuotedValues(valueList: string): string[] {
  35. const values: string[] = [];
  36. let quote: '"' | "'" | null = null;
  37. let escaped = false;
  38. let current = '';
  39. for (const ch of valueList) {
  40. if (!quote) {
  41. if (ch === '"' || ch === "'") {
  42. quote = ch;
  43. current = '';
  44. }
  45. continue;
  46. }
  47. if (escaped) {
  48. current += ch;
  49. escaped = false;
  50. continue;
  51. }
  52. if (ch === '\\') {
  53. escaped = true;
  54. continue;
  55. }
  56. if (ch === quote) {
  57. values.push(current.trim());
  58. quote = null;
  59. current = '';
  60. continue;
  61. }
  62. current += ch;
  63. }
  64. return values.filter(Boolean);
  65. }
  66. function escapeRegExp(value: string): string {
  67. return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  68. }
  69. function getArrayValue(section: string, key: string): string | null {
  70. const keyRegex = new RegExp(`\\b${escapeRegExp(key)}\\b\\s*=`, 'm');
  71. const keyMatch = keyRegex.exec(section);
  72. if (!keyMatch) return null;
  73. let i = keyMatch.index + keyMatch[0].length;
  74. while (i < section.length && /\s/.test(section.charAt(i))) i++;
  75. if (section.charAt(i) !== '[') return null;
  76. i++;
  77. let inQuote: '"' | "'" | null = null;
  78. let escaped = false;
  79. let depth = 1;
  80. const start = i;
  81. while (i < section.length) {
  82. const ch = section.charAt(i);
  83. if (inQuote) {
  84. if (escaped) {
  85. escaped = false;
  86. } else if (ch === '\\') {
  87. escaped = true;
  88. } else if (ch === inQuote) {
  89. inQuote = null;
  90. }
  91. i++;
  92. continue;
  93. }
  94. if (ch === '"' || ch === "'") {
  95. inQuote = ch;
  96. i++;
  97. continue;
  98. }
  99. if (ch === '[') {
  100. depth++;
  101. i++;
  102. continue;
  103. }
  104. if (ch === ']') {
  105. depth--;
  106. if (depth === 0) {
  107. return section.slice(start, i);
  108. }
  109. i++;
  110. continue;
  111. }
  112. i++;
  113. }
  114. return null;
  115. }
  116. function parseWorkspaceMembers(cargoToml: string): string[] {
  117. const workspaceSection = getSection(cargoToml, 'workspace');
  118. if (!workspaceSection) return [];
  119. const membersValue = getArrayValue(workspaceSection, 'members');
  120. if (!membersValue) return [];
  121. return extractQuotedValues(membersValue);
  122. }
  123. function parsePackageName(cargoToml: string): string | null {
  124. const packageSection = getSection(cargoToml, 'package');
  125. if (!packageSection) return null;
  126. const packageNameMatch = packageSection.match(/name\s*=\s*["']([^"'\n]+)["']/);
  127. return packageNameMatch?.[1]?.trim() ?? null;
  128. }
  129. function addCrateAlias(map: Map<string, string>, crateName: string, memberPath: string): void {
  130. const normalized = crateName.replace(/-/g, '_');
  131. map.set(crateName, memberPath);
  132. if (normalized !== crateName) {
  133. map.set(normalized, memberPath);
  134. }
  135. }
  136. function cleanPath(memberPath: string): string {
  137. return memberPath.replace(/\\/g, '/').replace(/\/$/, '');
  138. }
  139. function expandGlobMember(member: string, context: ResolutionContext): string[] {
  140. if (!context.listDirectories) return [];
  141. const firstGlobIdx = member.search(GLOB_CHARS);
  142. const staticPrefix = member
  143. .slice(0, firstGlobIdx)
  144. .replace(/[^/]*$/, '')
  145. .replace(/\/$/, '');
  146. const matcher = picomatch(member, { dot: false });
  147. const matches: string[] = [];
  148. const seen = new Set<string>();
  149. function walk(dir: string, depth: number): void {
  150. if (depth > MAX_GLOB_WALK_DEPTH) return;
  151. const children = context.listDirectories!(dir);
  152. for (const child of children) {
  153. if (SKIP_DIRS.has(child) || child.startsWith('.')) continue;
  154. const rel = dir === '.' ? child : `${dir}/${child}`;
  155. if (matcher(rel) && !seen.has(rel)) {
  156. seen.add(rel);
  157. matches.push(rel);
  158. }
  159. walk(rel, depth + 1);
  160. }
  161. }
  162. walk(staticPrefix || '.', 0);
  163. return matches;
  164. }
  165. function expandMembers(members: string[], context: ResolutionContext): string[] {
  166. const expanded: string[] = [];
  167. const seen = new Set<string>();
  168. for (const member of members) {
  169. const candidates = GLOB_CHARS.test(member)
  170. ? expandGlobMember(member, context)
  171. : [member];
  172. for (const candidate of candidates) {
  173. const cleaned = cleanPath(candidate);
  174. if (seen.has(cleaned)) continue;
  175. seen.add(cleaned);
  176. expanded.push(cleaned);
  177. }
  178. }
  179. return expanded;
  180. }
  181. /**
  182. * Build a map from crate-name aliases to workspace member directory paths.
  183. * Example: "mytool-core" and "mytool_core" -> "crates/mytool-core"
  184. *
  185. * Supports glob members (e.g. `members = ["crates/*"]`) via picomatch
  186. * when the context exposes `listDirectories`.
  187. */
  188. export function getCargoWorkspaceCrateMap(context: ResolutionContext): Map<string, string> {
  189. const result = new Map<string, string>();
  190. const rootCargoToml = context.readFile('Cargo.toml');
  191. if (!rootCargoToml) return result;
  192. const rawMembers = parseWorkspaceMembers(rootCargoToml);
  193. const members = expandMembers(rawMembers, context);
  194. for (const memberPath of members) {
  195. const memberCargoPath = `${memberPath}/Cargo.toml`;
  196. const memberCargoToml = context.readFile(memberCargoPath);
  197. if (!memberCargoToml) continue;
  198. const packageName = parsePackageName(memberCargoToml);
  199. if (!packageName) continue;
  200. addCrateAlias(result, packageName, memberPath);
  201. }
  202. return result;
  203. }