@suss/packs 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/rails.js CHANGED
@@ -1,35 +1,177 @@
1
1
  // ../framework/rails/dist/index.js
2
- import path from "path";
2
+ import fs4 from "fs";
3
+ import path4 from "path";
3
4
  import { z } from "zod";
4
- import { underscoreConstantPath } from "@suss/adapter-ruby";
5
+ import { underscoreConstantPath as underscoreConstantPath2 } from "@suss/adapter-ruby";
5
6
  import fs from "fs";
7
+ import path from "path";
6
8
  import {
7
9
  bodyStatements,
8
10
  field,
9
- hashKeySymbolName,
10
11
  parseRubySync,
12
+ underscoreConstantPath
13
+ } from "@suss/adapter-ruby";
14
+ import fs2 from "fs";
15
+ import path2 from "path";
16
+ import fs3 from "fs";
17
+ import path3 from "path";
18
+ import {
19
+ bodyStatements as bodyStatements2,
20
+ field as field2,
21
+ hashKeySymbolName,
22
+ nestedStatements,
23
+ parseRubySync as parseRubySync2,
11
24
  stringValueOf,
12
25
  symbolValue
13
26
  } from "@suss/adapter-ruby";
14
- function readSimpleArgs(call) {
15
- const argumentList = field(call, "arguments");
27
+ var ENGINE_BASE_NAMES = /* @__PURE__ */ new Set(["Rails::Engine", "::Rails::Engine"]);
28
+ var CONSTANT_TYPES = /* @__PURE__ */ new Set(["constant", "scope_resolution"]);
29
+ function readEngines(roots, acronyms = []) {
30
+ const engines = [];
31
+ for (const root of roots) {
32
+ const routesFile = engineRoutesFile(root);
33
+ for (const file of engineFilesUnder(root)) {
34
+ const source = fs.readFileSync(file, "utf8");
35
+ const rootNode = parseRubySync(source).rootNode;
36
+ for (const found of engineClassesIn(rootNode, acronyms)) {
37
+ engines.push({ ...found, routesFile });
38
+ }
39
+ }
40
+ }
41
+ return engines;
42
+ }
43
+ function engineSourceFiles(root) {
44
+ const routesFile = engineRoutesFile(root);
45
+ return [
46
+ ...engineFilesUnder(root),
47
+ ...routesFile === null ? [] : [routesFile]
48
+ ];
49
+ }
50
+ function engineRoutesFile(root) {
51
+ const routesFile = path.join(root, "config", "routes.rb");
52
+ return fs.existsSync(routesFile) ? routesFile : null;
53
+ }
54
+ function engineFilesUnder(root) {
55
+ const directory = path.join(root, "lib");
56
+ if (!fs.existsSync(directory)) {
57
+ return [];
58
+ }
59
+ return rubyFilesUnder(directory).filter(
60
+ (file) => fs.readFileSync(file, "utf8").includes("Rails::Engine")
61
+ );
62
+ }
63
+ function expandPathPattern(pattern) {
64
+ const segments = pattern.split(path.sep).filter((s) => s !== "");
65
+ const start = path.isAbsolute(pattern) ? path.sep : ".";
66
+ let candidates = [start];
67
+ for (const segment of segments) {
68
+ if (!segment.includes("*")) {
69
+ candidates = candidates.map((base) => path.join(base, segment));
70
+ continue;
71
+ }
72
+ const matcher = new RegExp(
73
+ `^${segment.split("*").map(escapeRegExp).join(".*")}$`
74
+ );
75
+ candidates = candidates.flatMap(
76
+ (base) => fs.existsSync(base) ? fs.readdirSync(base).filter((name) => matcher.test(name)).sort().map((name) => path.join(base, name)) : []
77
+ );
78
+ }
79
+ return candidates.filter((candidate) => fs.existsSync(candidate));
80
+ }
81
+ function escapeRegExp(text) {
82
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
83
+ }
84
+ function rubyFilesUnder(directory) {
85
+ return fs.readdirSync(directory, { withFileTypes: true, recursive: true }).filter((entry) => entry.isFile() && entry.name.endsWith(".rb")).map((entry) => path.join(entry.parentPath, entry.name)).sort();
86
+ }
87
+ function engineClassesIn(node, acronyms, enclosing = []) {
88
+ const found = [];
89
+ for (const child of node.namedChildren) {
90
+ if (child === null) {
91
+ continue;
92
+ }
93
+ if (child.type === "class" && extendsEngine(child)) {
94
+ const name = definedName(child);
95
+ if (name !== void 0) {
96
+ found.push({
97
+ qualifiedName: [...enclosing, name].join("::"),
98
+ modulePrefix: isolatedNamespaceOf(child, acronyms)
99
+ });
100
+ }
101
+ continue;
102
+ }
103
+ const nested = child.type === "module" || child.type === "class" ? [...enclosing, definedName(child) ?? ""] : enclosing;
104
+ found.push(...engineClassesIn(child, acronyms, nested));
105
+ }
106
+ return found;
107
+ }
108
+ function definedName(definition) {
109
+ return field(definition, "name")?.text.replace(/^::/, "");
110
+ }
111
+ function extendsEngine(classNode) {
112
+ const superclass = field(classNode, "superclass");
113
+ const base = superclass?.namedChildren.find(
114
+ (child) => child !== null && CONSTANT_TYPES.has(child.type)
115
+ );
116
+ return base !== void 0 && base !== null && ENGINE_BASE_NAMES.has(base.text);
117
+ }
118
+ function isolatedNamespaceOf(classNode, acronyms) {
119
+ const body = field(classNode, "body");
120
+ if (body === null) {
121
+ return "";
122
+ }
123
+ for (const statement of bodyStatements(body)) {
124
+ if (statement.type !== "call" || field(statement, "method")?.text !== "isolate_namespace") {
125
+ continue;
126
+ }
127
+ const arguments_ = field(statement, "arguments");
128
+ const argument = arguments_ === null ? void 0 : bodyStatements(arguments_)[0];
129
+ if (argument !== void 0 && CONSTANT_TYPES.has(argument.type)) {
130
+ return underscoreConstantPath(argument.text.replace(/^::/, ""), acronyms);
131
+ }
132
+ }
133
+ return "";
134
+ }
135
+ var ACRONYM_CALL = /\.acronym\s*\(?\s*(["'])([A-Za-z\d]+)\1/g;
136
+ function readAcronyms(configDirectory) {
137
+ const found = /* @__PURE__ */ new Set();
138
+ for (const file of inflectionFiles(configDirectory)) {
139
+ const source = fs2.readFileSync(file, "utf8");
140
+ for (const match of source.matchAll(ACRONYM_CALL)) {
141
+ found.add(match[2] ?? "");
142
+ }
143
+ }
144
+ found.delete("");
145
+ return [...found].sort();
146
+ }
147
+ function inflectionFiles(configDirectory) {
148
+ const directory = path2.join(configDirectory, "config", "initializers");
149
+ if (!fs2.existsSync(directory)) {
150
+ return [];
151
+ }
152
+ return fs2.readdirSync(directory, { withFileTypes: true, recursive: true }).filter((entry) => entry.isFile() && entry.name.endsWith(".rb")).map((entry) => path2.join(entry.parentPath, entry.name)).filter(
153
+ (file) => fs2.readFileSync(file, "utf8").match(ACRONYM_CALL) !== null
154
+ ).sort();
155
+ }
156
+ function readSimpleArgs(call, defaults = {}) {
157
+ const argumentList = field2(call, "arguments");
16
158
  const positional = [];
17
- const keyword = {};
159
+ const keyword = { ...defaults };
18
160
  let hashRocketPair = null;
19
161
  if (argumentList === null) {
20
162
  return { positional, keyword, hashRocketPair };
21
163
  }
22
- for (const child of bodyStatements(argumentList)) {
164
+ for (const child of bodyStatements2(argumentList)) {
23
165
  if (child.type !== "pair") {
24
166
  positional.push(child);
25
167
  continue;
26
168
  }
27
- const keyNode = field(child, "key");
28
- const valueNode = field(child, "value");
169
+ const keyNode = field2(child, "key");
170
+ const valueNode = field2(child, "value");
29
171
  if (keyNode === null || valueNode === null) {
30
172
  continue;
31
173
  }
32
- const symbolKey = hashKeySymbolName(keyNode);
174
+ const symbolKey = hashKeySymbolName(keyNode) ?? symbolValue(keyNode);
33
175
  if (symbolKey !== null) {
34
176
  keyword[symbolKey] = valueNode;
35
177
  } else {
@@ -38,11 +180,14 @@ function readSimpleArgs(call) {
38
180
  }
39
181
  return { positional, keyword, hashRocketPair };
40
182
  }
41
- function wordValue(node) {
183
+ function textValue(node, ctx) {
184
+ return stringValueOf(node, void 0, ctx.bindings);
185
+ }
186
+ function wordValue(node, ctx) {
42
187
  if (node === void 0) {
43
188
  return null;
44
189
  }
45
- return symbolValue(node) ?? stringValueOf(node);
190
+ return symbolValue(node) ?? textValue(node, ctx);
46
191
  }
47
192
  function splitControllerAction(text) {
48
193
  const index = text.indexOf("#");
@@ -51,15 +196,15 @@ function splitControllerAction(text) {
51
196
  }
52
197
  return { controllerKey: text.slice(0, index), action: text.slice(index + 1) };
53
198
  }
54
- function readRouteTarget(args) {
55
- const toText = args.keyword.to ? stringValueOf(args.keyword.to) : null;
199
+ function readRouteTarget(args, ctx) {
200
+ const toText = args.keyword.to ? textValue(args.keyword.to, ctx) : null;
56
201
  if (toText !== null) {
57
202
  const target = splitControllerAction(toText);
58
203
  return target === null ? null : { ...target, path: null };
59
204
  }
60
205
  if (args.hashRocketPair !== null) {
61
- const pathText = stringValueOf(args.hashRocketPair.key);
62
- const actionText = stringValueOf(args.hashRocketPair.value);
206
+ const pathText = textValue(args.hashRocketPair.key, ctx);
207
+ const actionText = textValue(args.hashRocketPair.value, ctx);
63
208
  const target = actionText !== null ? splitControllerAction(actionText) : null;
64
209
  return target !== null && pathText !== null ? { ...target, path: pathText } : null;
65
210
  }
@@ -70,7 +215,7 @@ function readSymbolList(node) {
70
215
  return null;
71
216
  }
72
217
  if (node.type === "array") {
73
- return bodyStatements(node).map((child) => symbolValue(child)).filter((name) => name !== null);
218
+ return bodyStatements2(node).map((child) => symbolValue(child)).filter((name) => name !== null);
74
219
  }
75
220
  const single = symbolValue(node);
76
221
  return single === null ? null : [single];
@@ -96,10 +241,10 @@ function pluralize(word) {
96
241
  if (/[^aeiou]y$/.test(word)) {
97
242
  return `${word.slice(0, -1)}ies`;
98
243
  }
99
- if (/(s|x|z|ch|sh)$/.test(word)) {
244
+ if (/(ss|us|is|x|z|ch|sh)$/.test(word)) {
100
245
  return `${word}es`;
101
246
  }
102
- return `${word}s`;
247
+ return /s$/.test(word) ? word : `${word}s`;
103
248
  }
104
249
  function singularize(word) {
105
250
  if (/ies$/.test(word)) {
@@ -111,27 +256,79 @@ function singularize(word) {
111
256
  return /s$/.test(word) ? word.slice(0, -1) : word;
112
257
  }
113
258
  var RouteAccumulator = class {
259
+ /** `drawDirectory` is where `draw(:name)` finds `name.rb`. */
260
+ constructor(drawDirectory) {
261
+ this.drawDirectory = drawDirectory;
262
+ }
263
+ drawDirectory;
114
264
  byKey = /* @__PURE__ */ new Map();
115
- unread = /* @__PURE__ */ new Set();
265
+ unread = /* @__PURE__ */ new Map();
266
+ /** Per file, each gem block call whose body was walked as though the call changed nothing about it. */
267
+ walkedBlocks = /* @__PURE__ */ new Map();
268
+ missingDrawn = [];
269
+ /** The block each `concern :name do ... end` declared, for `concerns` to replay. */
270
+ concerns = /* @__PURE__ */ new Map();
271
+ /** Files `draw(:name)` has already read, so a file drawing itself stops. */
272
+ drawn = /* @__PURE__ */ new Set();
273
+ /** Every engine a `mount` may refer to, by the class name it is written with. */
274
+ engines = /* @__PURE__ */ new Map();
275
+ /** Engines whose route set is being walked, so an engine that mounts itself stops. */
276
+ mounting = /* @__PURE__ */ new Set();
277
+ /** The file whose statements are being walked, as a gap writes it. */
278
+ file = "";
279
+ /** The first route written for an action wins, the way Rails matches the first route it declared. */
116
280
  add(controllerKey, action, route) {
117
- this.byKey.set(`${controllerKey}#${action}`, route);
281
+ const key = `${controllerKey}#${action}`;
282
+ if (!this.byKey.has(key)) {
283
+ this.byKey.set(key, route);
284
+ }
118
285
  }
119
286
  recordUnread(callName) {
120
- this.unread.add(callName);
287
+ addUnderFile(this.unread, this.file, callName);
288
+ }
289
+ recordWalkedBlock(callName) {
290
+ addUnderFile(this.walkedBlocks, this.file, callName);
291
+ }
292
+ recordMissingDrawn(name) {
293
+ this.missingDrawn.push({ file: this.file, name });
121
294
  }
122
295
  routeFor(controllerKey, action) {
123
296
  return this.byKey.get(`${controllerKey}#${action}`) ?? null;
124
297
  }
125
- gapsAgainst(displayPath) {
126
- if (this.unread.size === 0) {
127
- return [];
298
+ /** Runs `walk` with gaps attributed to `file`, then goes back to the file being walked before. */
299
+ inFile(file, walk) {
300
+ const previous = this.file;
301
+ this.file = file;
302
+ walk();
303
+ this.file = previous;
304
+ }
305
+ gaps() {
306
+ const gaps = [];
307
+ for (const [file, unread] of this.unread) {
308
+ const names = [...unread].sort().join(", ");
309
+ gaps.push(
310
+ `${file} also declares ${names}, which this pack does not read; whatever those declarations route is missing from what suss reports`
311
+ );
128
312
  }
129
- const names = [...this.unread].sort().join(", ");
130
- return [
131
- `${displayPath} also declares ${names}, which this pack does not read; whatever those declarations route is missing from what suss reports`
132
- ];
313
+ for (const [file, walked] of this.walkedBlocks) {
314
+ const names = [...walked].sort().join(", ");
315
+ gaps.push(
316
+ `${file} wraps routes in ${names}, which this pack does not know; it read the routes inside as though the wrapper changed nothing about their path or controller`
317
+ );
318
+ }
319
+ for (const { file, name } of this.missingDrawn) {
320
+ gaps.push(
321
+ `${file} draws ${name}, but there is no ${name}.rb under ${path3.basename(this.drawDirectory)}/ beside it to read; whatever that file routes is missing from what suss reports`
322
+ );
323
+ }
324
+ return gaps;
133
325
  }
134
326
  };
327
+ function addUnderFile(byFile, file, name) {
328
+ const names = byFile.get(file) ?? /* @__PURE__ */ new Set();
329
+ names.add(name);
330
+ byFile.set(file, names);
331
+ }
135
332
  function joinKey(prefix, segment) {
136
333
  return prefix === "" ? segment : `${prefix}/${segment}`;
137
334
  }
@@ -141,15 +338,20 @@ function joinPath(prefix, segment) {
141
338
  return trimmed === "" ? base || "/" : `${base}/${trimmed}`;
142
339
  }
143
340
  function handleResourceCall(call, ctx, out, plural) {
144
- const args = readSimpleArgs(call);
341
+ const args = readSimpleArgs(call, ctx.defaults);
145
342
  const symbol = args.positional[0] ? symbolValue(args.positional[0]) : null;
146
343
  if (symbol === null) {
147
344
  return;
148
345
  }
149
- const controllerSegment = wordValue(args.keyword.controller) ?? (plural ? symbol : pluralize(symbol));
150
- const controllerKey = joinKey(ctx.modulePrefix, controllerSegment);
346
+ const controllerSegment = wordValue(args.keyword.controller, ctx) ?? (plural ? symbol : pluralize(symbol));
347
+ const moduleName = wordValue(args.keyword.module, ctx);
348
+ const modulePrefix = moduleName === null ? ctx.modulePrefix : joinKey(ctx.modulePrefix, moduleName);
349
+ const controllerKey = joinKey(modulePrefix, controllerSegment);
151
350
  const prefixBase = ctx.resource !== void 0 ? ctx.resource.nestedBase : ctx.pathPrefix;
152
- const base = joinPath(prefixBase, wordValue(args.keyword.path) ?? symbol);
351
+ const base = joinPath(
352
+ prefixBase,
353
+ wordValue(args.keyword.path, ctx) ?? symbol
354
+ );
153
355
  const only = readSymbolList(args.keyword.only);
154
356
  const except = readSymbolList(args.keyword.except);
155
357
  const table = plural ? RESTFUL_ROUTES_PLURAL : RESTFUL_ROUTES_SINGULAR;
@@ -162,11 +364,6 @@ function handleResourceCall(call, ctx, out, plural) {
162
364
  }
163
365
  out.add(controllerKey, action, routeAt(base));
164
366
  }
165
- const block = field(call, "block");
166
- const body = block !== null ? field(block, "body") : null;
167
- if (body === null) {
168
- return;
169
- }
170
367
  const nestedBase = plural ? `${base}/:${singularize(symbol)}_id` : base;
171
368
  const nested = {
172
369
  controllerKey,
@@ -175,14 +372,20 @@ function handleResourceCall(call, ctx, out, plural) {
175
372
  nestedBase,
176
373
  ambientBase: nestedBase
177
374
  };
178
- walkBody(body, { ...ctx, resource: nested }, out);
375
+ const inside = { ...ctx, modulePrefix, resource: nested };
376
+ applyConcerns(readSymbolList(args.keyword.concerns) ?? [], inside, out);
377
+ const block = field2(call, "block");
378
+ const body = block !== null ? field2(block, "body") : null;
379
+ if (body !== null) {
380
+ walkBody(body, inside, out);
381
+ }
179
382
  }
180
383
  function handleOnBlock(call, ctx, out, isMember) {
181
384
  if (ctx.resource === void 0) {
182
385
  return;
183
386
  }
184
- const block = field(call, "block");
185
- const body = block !== null ? field(block, "body") : null;
387
+ const block = field2(call, "block");
388
+ const body = block !== null ? field2(block, "body") : null;
186
389
  if (body === null) {
187
390
  return;
188
391
  }
@@ -199,35 +402,36 @@ function baseForOn(resource, on) {
199
402
  return resource.ambientBase;
200
403
  }
201
404
  function handleVerb(call, ctx, out, method) {
202
- const args = readSimpleArgs(call);
203
- const target = readRouteTarget(args);
405
+ const args = readSimpleArgs(call, ctx.defaults);
406
+ const on = wordValue(args.keyword.on, ctx);
407
+ const base = ctx.resource === void 0 ? ctx.pathPrefix : baseForOn(ctx.resource, on);
408
+ const target = readRouteTarget(args, ctx);
204
409
  if (target !== null) {
205
- const literalPath = args.positional[0] ? stringValueOf(args.positional[0]) : null;
206
- const path2 = target.path ?? literalPath;
207
- if (path2 !== null) {
410
+ const literalPath = args.positional[0] ? textValue(args.positional[0], ctx) : null;
411
+ const path5 = target.path ?? literalPath;
412
+ if (path5 !== null) {
208
413
  out.add(joinKey(ctx.modulePrefix, target.controllerKey), target.action, {
209
414
  method,
210
- path: joinPath(ctx.pathPrefix, path2)
415
+ path: joinPath(base, path5)
211
416
  });
212
417
  }
213
418
  return;
214
419
  }
215
- if (ctx.resource === void 0) {
420
+ const segment = args.positional[0] ? wordValue(args.positional[0], ctx) : null;
421
+ if (segment === null) {
216
422
  return;
217
423
  }
218
- const symbol = args.positional[0] ? symbolValue(args.positional[0]) : null;
219
- if (symbol === null) {
424
+ const action = wordValue(args.keyword.action, ctx) ?? segment;
425
+ const controller = wordValue(args.keyword.controller, ctx);
426
+ const controllerKey = controller !== null ? joinKey(ctx.modulePrefix, controller) : ctx.resource?.controllerKey;
427
+ if (controllerKey === void 0) {
220
428
  return;
221
429
  }
222
- const base = baseForOn(ctx.resource, wordValue(args.keyword.on));
223
- out.add(ctx.resource.controllerKey, symbol, {
224
- method,
225
- path: `${base}/${symbol}`
226
- });
430
+ out.add(controllerKey, action, { method, path: joinPath(base, segment) });
227
431
  }
228
432
  function handleRoot(call, ctx, out) {
229
- const args = readSimpleArgs(call);
230
- const target = readRouteTarget(args) ?? (args.positional[0] ? splitControllerAction(stringValueOf(args.positional[0]) ?? "") : null);
433
+ const args = readSimpleArgs(call, ctx.defaults);
434
+ const target = readRouteTarget(args, ctx) ?? (args.positional[0] ? splitControllerAction(textValue(args.positional[0], ctx) ?? "") : null);
231
435
  if (target !== null) {
232
436
  out.add(joinKey(ctx.modulePrefix, target.controllerKey), target.action, {
233
437
  method: "GET",
@@ -236,40 +440,146 @@ function handleRoot(call, ctx, out) {
236
440
  }
237
441
  }
238
442
  function handleNamespace(call, ctx, out) {
239
- const args = readSimpleArgs(call);
443
+ const args = readSimpleArgs(call, ctx.defaults);
240
444
  const symbol = args.positional[0] ? symbolValue(args.positional[0]) : null;
241
- const block = field(call, "block");
242
- const body = block !== null ? field(block, "body") : null;
445
+ const block = field2(call, "block");
446
+ const body = block !== null ? field2(block, "body") : null;
243
447
  if (symbol === null || body === null) {
244
448
  return;
245
449
  }
246
- walkBody(
247
- body,
248
- {
249
- pathPrefix: joinPath(ctx.pathPrefix, symbol),
250
- modulePrefix: joinKey(ctx.modulePrefix, symbol)
251
- },
252
- out
253
- );
450
+ walkBody(body, enterScope(ctx, symbol, symbol), out);
451
+ }
452
+ function enterScope(ctx, pathSegment, moduleName) {
453
+ const base = ctx.resource?.nestedBase ?? ctx.pathPrefix;
454
+ const { resource: _left, ...kept } = ctx;
455
+ return {
456
+ ...kept,
457
+ pathPrefix: pathSegment !== null ? joinPath(base, pathSegment) : base,
458
+ modulePrefix: moduleName !== null ? joinKey(ctx.modulePrefix, moduleName) : ctx.modulePrefix
459
+ };
254
460
  }
255
461
  function handleScope(call, ctx, out) {
256
- const args = readSimpleArgs(call);
257
- const moduleName = wordValue(args.keyword.module);
258
- const pathSegment = wordValue(args.keyword.path) ?? (args.positional[0] ? wordValue(args.positional[0]) : null);
259
- const block = field(call, "block");
260
- const body = block !== null ? field(block, "body") : null;
462
+ const args = readSimpleArgs(call, ctx.defaults);
463
+ const moduleName = wordValue(args.keyword.module, ctx);
464
+ const pathSegment = wordValue(args.keyword.path, ctx) ?? (args.positional[0] ? wordValue(args.positional[0], ctx) : null);
465
+ const block = field2(call, "block");
466
+ const body = block !== null ? field2(block, "body") : null;
261
467
  if (body === null) {
262
468
  return;
263
469
  }
264
- walkBody(
265
- body,
266
- {
267
- pathPrefix: pathSegment !== null ? joinPath(ctx.pathPrefix, pathSegment) : ctx.pathPrefix,
268
- modulePrefix: moduleName !== null ? joinKey(ctx.modulePrefix, moduleName) : ctx.modulePrefix
269
- },
270
- out
470
+ walkBody(body, enterScope(ctx, pathSegment, moduleName), out);
471
+ }
472
+ var LITERAL_LIST_TYPES = /* @__PURE__ */ new Set(["array", "string_array", "symbol_array"]);
473
+ var LOOP_METHODS = /* @__PURE__ */ new Set(["each", "each_with_index"]);
474
+ function replayLiteralLoop(call, ctx, out) {
475
+ const receiver = field2(call, "receiver");
476
+ const method = field2(call, "method")?.text;
477
+ const block = field2(call, "block");
478
+ const body = block !== null ? field2(block, "body") : null;
479
+ const parameters = block !== null ? field2(block, "parameters") : null;
480
+ if (receiver === null || !LITERAL_LIST_TYPES.has(receiver.type) || method === void 0 || !LOOP_METHODS.has(method) || body === null || parameters === null) {
481
+ return;
482
+ }
483
+ const [element, index] = bodyStatements2(parameters).map((p) => p.text);
484
+ if (element === void 0) {
485
+ return;
486
+ }
487
+ const elements = bodyStatements2(receiver).map(
488
+ (node) => listElementWord(node, ctx)
489
+ );
490
+ elements.forEach((word, position) => {
491
+ if (word === null) {
492
+ return;
493
+ }
494
+ const bindings = new Map(ctx.bindings ?? []);
495
+ bindings.set(element, word);
496
+ if (index !== void 0) {
497
+ bindings.set(index, String(position));
498
+ }
499
+ walkBody(body, { ...ctx, bindings }, out);
500
+ });
501
+ }
502
+ function listElementWord(node, ctx) {
503
+ if (node.type === "bare_symbol") {
504
+ return node.text;
505
+ }
506
+ return wordValue(node, ctx);
507
+ }
508
+ function walkBlockInPlace(call, ctx, out) {
509
+ const block = field2(call, "block");
510
+ const body = block !== null ? field2(block, "body") : null;
511
+ if (body !== null) {
512
+ walkBody(body, ctx, out);
513
+ }
514
+ }
515
+ function handleWithOptions(call, ctx, out) {
516
+ const args = readSimpleArgs(call, ctx.defaults);
517
+ const block = field2(call, "block");
518
+ const body = block !== null ? field2(block, "body") : null;
519
+ if (body === null) {
520
+ return;
521
+ }
522
+ walkBody(body, { ...ctx, defaults: args.keyword }, out);
523
+ }
524
+ function handleConcern(call, ctx, out) {
525
+ const args = readSimpleArgs(call, ctx.defaults);
526
+ const name = args.positional[0] ? symbolValue(args.positional[0]) : null;
527
+ const block = field2(call, "block");
528
+ const body = block !== null ? field2(block, "body") : null;
529
+ if (name !== null && body !== null) {
530
+ out.concerns.set(name, body);
531
+ }
532
+ }
533
+ function applyConcerns(names, ctx, out) {
534
+ for (const name of names) {
535
+ const body = out.concerns.get(name);
536
+ if (body !== void 0) {
537
+ walkBody(body, ctx, out);
538
+ }
539
+ }
540
+ }
541
+ function handleConcerns(call, ctx, out) {
542
+ const args = readSimpleArgs(call, ctx.defaults);
543
+ const names = args.positional.map((node) => symbolValue(node)).filter((name) => name !== null);
544
+ applyConcerns(names, ctx, out);
545
+ }
546
+ function methodsOfVia(node) {
547
+ const names = readSymbolList(node) ?? [];
548
+ if (names.includes("all")) {
549
+ return ["*"];
550
+ }
551
+ return names.map((name) => name.toUpperCase());
552
+ }
553
+ function handleMatch(call, ctx, out) {
554
+ const via = readSimpleArgs(call, ctx.defaults).keyword.via;
555
+ for (const method of methodsOfVia(via)) {
556
+ handleVerb(call, ctx, out, method);
557
+ }
558
+ }
559
+ function drawDirectoryOf(routesFile) {
560
+ return path3.join(
561
+ path3.dirname(routesFile),
562
+ path3.basename(routesFile, path3.extname(routesFile))
271
563
  );
272
564
  }
565
+ function handleDraw(call, ctx, out) {
566
+ const args = readSimpleArgs(call, ctx.defaults);
567
+ const name = args.positional[0] ? wordValue(args.positional[0], ctx) : null;
568
+ if (name === null) {
569
+ return;
570
+ }
571
+ const file = path3.join(out.drawDirectory, `${name}.rb`);
572
+ if (out.drawn.has(file)) {
573
+ return;
574
+ }
575
+ out.drawn.add(file);
576
+ if (!fs3.existsSync(file)) {
577
+ out.recordMissingDrawn(name);
578
+ return;
579
+ }
580
+ const tree = parseRubySync2(fs3.readFileSync(file, "utf8"));
581
+ walkBody(tree.rootNode, ctx, out);
582
+ }
273
583
  var HANDLERS = {
274
584
  resources: (call, ctx, out) => handleResourceCall(call, ctx, out, true),
275
585
  resource: (call, ctx, out) => handleResourceCall(call, ctx, out, false),
@@ -282,59 +592,182 @@ var HANDLERS = {
282
592
  post: (call, ctx, out) => handleVerb(call, ctx, out, "POST"),
283
593
  patch: (call, ctx, out) => handleVerb(call, ctx, out, "PATCH"),
284
594
  put: (call, ctx, out) => handleVerb(call, ctx, out, "PUT"),
285
- delete: (call, ctx, out) => handleVerb(call, ctx, out, "DELETE")
595
+ delete: (call, ctx, out) => handleVerb(call, ctx, out, "DELETE"),
596
+ match: handleMatch,
597
+ draw: handleDraw,
598
+ constraints: walkBlockInPlace,
599
+ with_options: handleWithOptions,
600
+ concern: handleConcern,
601
+ concerns: handleConcerns,
602
+ mount: handleMount
286
603
  };
604
+ function handleMount(call, ctx, out) {
605
+ const args = readSimpleArgs(call, ctx.defaults);
606
+ const target = args.positional[0] ?? args.hashRocketPair?.key;
607
+ const at = args.keyword.at ?? args.hashRocketPair?.value;
608
+ const name = target?.text.replace(/^::/, "");
609
+ const engine = name === void 0 ? void 0 : out.engines.get(name);
610
+ const mountPath = at === void 0 ? null : textValue(at, ctx);
611
+ if (name === void 0 || engine === void 0 || mountPath === null || out.mounting.has(name)) {
612
+ out.recordUnread("mount");
613
+ return;
614
+ }
615
+ if (engine.body === null) {
616
+ return;
617
+ }
618
+ out.mounting.add(name);
619
+ out.inFile(engine.displayPath, () => {
620
+ walkBody(
621
+ engine.body,
622
+ {
623
+ pathPrefix: joinPath(ctx.pathPrefix, mountPath),
624
+ modulePrefix: engine.modulePrefix,
625
+ defaults: {}
626
+ },
627
+ out
628
+ );
629
+ });
630
+ out.mounting.delete(name);
631
+ }
287
632
  function walkBody(body, ctx, out) {
288
- for (const statement of bodyStatements(body)) {
289
- if (statement.type !== "call" || field(statement, "receiver") !== null) {
633
+ walkStatements(bodyStatements2(body), ctx, out);
634
+ }
635
+ function walkStatements(statements, ctx, out) {
636
+ for (const statement of statements) {
637
+ if (statement.type !== "call") {
638
+ walkStatements(nestedStatements(statement), ctx, out);
639
+ continue;
640
+ }
641
+ if (field2(statement, "receiver") !== null) {
642
+ replayLiteralLoop(statement, ctx, out);
290
643
  continue;
291
644
  }
292
- const name = field(statement, "method")?.text;
645
+ const name = field2(statement, "method")?.text;
293
646
  if (name === void 0) {
294
647
  continue;
295
648
  }
296
649
  const handler = HANDLERS[name];
297
- if (handler === void 0) {
298
- out.recordUnread(name);
650
+ if (handler !== void 0) {
651
+ handler(statement, ctx, out);
652
+ continue;
653
+ }
654
+ if (blockWrittenInRoutesGrammar(statement)) {
655
+ out.recordWalkedBlock(name);
656
+ walkBlockInPlace(statement, ctx, out);
299
657
  continue;
300
658
  }
301
- handler(statement, ctx, out);
659
+ out.recordUnread(name);
302
660
  }
303
661
  }
304
- function drawBlockBody(root) {
305
- for (const statement of bodyStatements(root)) {
306
- if (statement.type !== "call") {
307
- continue;
662
+ function blockWrittenInRoutesGrammar(call) {
663
+ const block = field2(call, "block");
664
+ const body = block !== null ? field2(block, "body") : null;
665
+ if (body === null) {
666
+ return false;
667
+ }
668
+ return bodyStatements2(body).some(
669
+ (statement) => statement.type === "call" && field2(statement, "receiver") === null && Object.hasOwn(HANDLERS, field2(statement, "method")?.text ?? "")
670
+ );
671
+ }
672
+ var DECLARATION_ORDER = {
673
+ prepend: 0,
674
+ draw: 1,
675
+ append: 2
676
+ };
677
+ function isDeclarationKind(name) {
678
+ return name in DECLARATION_ORDER;
679
+ }
680
+ function routeDeclarations(root, displayPath) {
681
+ const found = [];
682
+ const visit = (node) => {
683
+ const declaration = asRouteDeclaration(node, displayPath);
684
+ if (declaration !== null) {
685
+ found.push(declaration);
686
+ return;
308
687
  }
309
- const block = field(statement, "block");
310
- const body = block !== null ? field(block, "body") : null;
311
- if (body !== null) {
312
- return body;
688
+ for (const child of node.namedChildren) {
689
+ if (child !== null) {
690
+ visit(child);
691
+ }
313
692
  }
693
+ };
694
+ visit(root);
695
+ return found;
696
+ }
697
+ function asRouteDeclaration(node, displayPath) {
698
+ if (node.type !== "call") {
699
+ return null;
314
700
  }
315
- return null;
701
+ const kind = field2(node, "method")?.text;
702
+ const receiver = field2(node, "receiver");
703
+ const block = field2(node, "block");
704
+ const body = block !== null ? field2(block, "body") : null;
705
+ if (kind === void 0 || !isDeclarationKind(kind) || receiver === null || receiver.type !== "call" || field2(receiver, "method")?.text !== "routes" || body === null) {
706
+ return null;
707
+ }
708
+ const owner = field2(receiver, "receiver");
709
+ if (owner === null) {
710
+ return null;
711
+ }
712
+ return { owner: owner.text.replace(/^::/, ""), kind, body, displayPath };
713
+ }
714
+ function isApplication(owner) {
715
+ return owner === "Rails.application" || /(^|::)Application$/.test(owner);
316
716
  }
317
- function readRoutesFile(absRoutesPath, displayPath) {
318
- if (!fs.existsSync(absRoutesPath)) {
717
+ function parseDeclarations(source) {
718
+ if (!fs3.existsSync(source.file)) {
719
+ return [];
720
+ }
721
+ const tree = parseRubySync2(fs3.readFileSync(source.file, "utf8"));
722
+ return routeDeclarations(tree.rootNode, source.displayPath);
723
+ }
724
+ function readRoutes(input) {
725
+ if (!fs3.existsSync(input.routesFile.file)) {
319
726
  return {
320
727
  routeFor: () => null,
321
728
  fileFound: false,
322
729
  gaps: [
323
- `${displayPath} does not exist, so this run assumes each action's path and method from Rails' RESTful naming convention instead of reading it from routing`
730
+ `${input.routesFile.displayPath} does not exist, so this run assumes each action's path and method from Rails' RESTful naming convention instead of reading it from routing`
324
731
  ]
325
732
  };
326
733
  }
327
- const source = fs.readFileSync(absRoutesPath, "utf8");
328
- const tree = parseRubySync(source);
329
- const out = new RouteAccumulator();
330
- const body = drawBlockBody(tree.rootNode);
331
- if (body !== null) {
332
- walkBody(body, { pathPrefix: "", modulePrefix: "" }, out);
734
+ const out = new RouteAccumulator(drawDirectoryOf(input.routesFile.file));
735
+ const engineDeclarations = input.engines.flatMap(
736
+ (engine) => engine.routesFile === null ? [] : parseDeclarations({
737
+ file: engine.routesFile,
738
+ displayPath: engine.displayPath
739
+ })
740
+ );
741
+ for (const engine of input.engines) {
742
+ out.engines.set(engine.qualifiedName, {
743
+ modulePrefix: engine.modulePrefix,
744
+ body: engineDeclarations.find(
745
+ (declaration) => declaration.owner === engine.qualifiedName
746
+ )?.body ?? null,
747
+ displayPath: engine.displayPath
748
+ });
749
+ }
750
+ const applicationDeclarations = [
751
+ ...parseDeclarations(input.routesFile),
752
+ ...input.routesFiles.flatMap(parseDeclarations),
753
+ ...engineDeclarations
754
+ ].filter((declaration) => isApplication(declaration.owner));
755
+ const ordered = applicationDeclarations.map((declaration, position) => ({ declaration, position })).sort(
756
+ (a, b) => DECLARATION_ORDER[a.declaration.kind] - DECLARATION_ORDER[b.declaration.kind] || a.position - b.position
757
+ );
758
+ for (const { declaration } of ordered) {
759
+ out.inFile(declaration.displayPath, () => {
760
+ walkBody(
761
+ declaration.body,
762
+ { pathPrefix: "", modulePrefix: "", defaults: {} },
763
+ out
764
+ );
765
+ });
333
766
  }
334
767
  return {
335
768
  routeFor: (controllerKey, action) => out.routeFor(controllerKey, action),
336
769
  fileFound: true,
337
- gaps: out.gapsAgainst(displayPath)
770
+ gaps: out.gaps()
338
771
  };
339
772
  }
340
773
  var RACK_STATUS_CODE_NAMES = {
@@ -424,7 +857,21 @@ var optionsSchema = z.object({
424
857
  */
425
858
  inheritedMethodNames: z.array(z.string()).optional(),
426
859
  /** Where this project's routes live, relative to `configDirectory` when there is one. Every Rails app scaffolds this at `config/routes.rb`. */
427
- routesFile: z.string().optional()
860
+ routesFile: z.string().optional(),
861
+ /**
862
+ * Directories the project keeps its own engines under, each one the
863
+ * root a `Rails::Engine` subclass has its `lib/` and `config/routes.rb`
864
+ * in. A `*` in a segment matches any one directory, so `plugins/*`
865
+ * covers every plugin. Relative to `configDirectory`.
866
+ */
867
+ engineRoots: z.array(z.string()).optional(),
868
+ /**
869
+ * Files beyond the routes file that add routes through a
870
+ * `Rails.application.routes.draw`, `.append` or `.prepend` block,
871
+ * a plugin's `plugin.rb` being the usual one. Same `*` rule as
872
+ * `engineRoots`. Relative to `configDirectory`.
873
+ */
874
+ routesFiles: z.array(z.string()).optional()
428
875
  }).strict();
429
876
  var RAILS_ROOT_CLASS_NAMES = [
430
877
  "ActionController::Base",
@@ -479,9 +926,9 @@ var RAILS_CONTROLLER_METHODS = [
479
926
  "controller_name",
480
927
  "controller_path"
481
928
  ];
482
- function controllerKeyFromQualified(qualifiedName) {
929
+ function controllerKeyFromQualified(qualifiedName, acronyms) {
483
930
  const withoutSuffix = qualifiedName.endsWith("Controller") ? qualifiedName.slice(0, -"Controller".length) : qualifiedName;
484
- return underscoreConstantPath(withoutSuffix);
931
+ return underscoreConstantPath2(withoutSuffix, acronyms);
485
932
  }
486
933
  function conventionalRoute(controllerKey, actionName) {
487
934
  const template = RESTFUL_ACTIONS[actionName];
@@ -495,10 +942,10 @@ function conventionalRoute(controllerKey, actionName) {
495
942
  };
496
943
  }
497
944
  function resolveAgainst(configDirectory, value) {
498
- if (configDirectory === void 0 || path.isAbsolute(value)) {
945
+ if (configDirectory === void 0 || path4.isAbsolute(value)) {
499
946
  return value;
500
947
  }
501
- return path.resolve(configDirectory, value);
948
+ return path4.resolve(configDirectory, value);
502
949
  }
503
950
  function railsFramework(options = {}) {
504
951
  const root = resolveAgainst(options.configDirectory, options.root ?? "app");
@@ -506,12 +953,27 @@ function railsFramework(options = {}) {
506
953
  options.configDirectory,
507
954
  options.routesFile ?? "config/routes.rb"
508
955
  );
956
+ const displayPathOf = (file) => options.configDirectory === void 0 ? file : path4.relative(options.configDirectory, file);
957
+ const engineRoots = () => expandPatterns(options.configDirectory, options.engineRoots);
958
+ const extraRoutesFiles = () => expandPatterns(options.configDirectory, options.routesFiles);
959
+ const acronyms = options.configDirectory === void 0 ? [] : readAcronyms(options.configDirectory);
960
+ const routesInput = () => ({
961
+ routesFile: {
962
+ file: routesFile,
963
+ displayPath: options.routesFile ?? "config/routes.rb"
964
+ },
965
+ engines: readEngines(engineRoots(), acronyms).map((engine) => ({
966
+ ...engine,
967
+ displayPath: engine.routesFile === null ? engine.qualifiedName : displayPathOf(engine.routesFile)
968
+ })),
969
+ routesFiles: extraRoutesFiles().map((file) => ({
970
+ file,
971
+ displayPath: displayPathOf(file)
972
+ }))
973
+ });
509
974
  let table;
510
975
  const routeTable = () => {
511
- table ??= readRoutesFile(
512
- routesFile,
513
- options.routesFile ?? "config/routes.rb"
514
- );
976
+ table ??= readRoutes(routesInput());
515
977
  return table;
516
978
  };
517
979
  const pattern = {
@@ -522,6 +984,7 @@ function railsFramework(options = {}) {
522
984
  ],
523
985
  root,
524
986
  pathConvention: "railsUnderscore",
987
+ acronyms,
525
988
  ancestryRootClassNames: [...RAILS_ROOT_CLASS_NAMES],
526
989
  defaultStatusCode: 200,
527
990
  responseStatusCalls: RESPONSE_STATUS_CALLS,
@@ -533,7 +996,7 @@ function railsFramework(options = {}) {
533
996
  routesFile,
534
997
  filters: CONTROLLER_FILTERS,
535
998
  routeFor: (controllerQualifiedName, actionName) => {
536
- const key = controllerKeyFromQualified(controllerQualifiedName);
999
+ const key = controllerKeyFromQualified(controllerQualifiedName, acronyms);
537
1000
  const found = routeTable();
538
1001
  return found.fileFound ? found.routeFor(key, actionName) : conventionalRoute(key, actionName);
539
1002
  },
@@ -543,21 +1006,50 @@ function railsFramework(options = {}) {
543
1006
  name: "rails",
544
1007
  protocol: "http",
545
1008
  discovery: [pattern],
546
- // The routes file decides every action's method and path but is
547
- // never walked, so the cache key has to read it here.
548
- discoveryInputs: () => [routesFile]
1009
+ // Every file routing or naming is read from decides an action's
1010
+ // binding without being walked, so the cache key has to read them
1011
+ // here. This runs before the grammar loads, so nothing parses Ruby.
1012
+ discoveryInputs: () => [
1013
+ routesFile,
1014
+ ...drawableRoutesFiles(routesFile),
1015
+ ...engineRoots().flatMap(engineSourceFiles),
1016
+ ...extraRoutesFiles(),
1017
+ ...options.configDirectory === void 0 ? [] : inflectionFiles(options.configDirectory)
1018
+ ]
549
1019
  };
550
1020
  }
1021
+ function expandPatterns(configDirectory, patterns) {
1022
+ return (patterns ?? []).flatMap(
1023
+ (pattern) => expandPathPattern(resolveAgainst(configDirectory, pattern))
1024
+ );
1025
+ }
1026
+ function drawableRoutesFiles(routesFile) {
1027
+ const directory = drawDirectoryOf(routesFile);
1028
+ if (!fs4.existsSync(directory)) {
1029
+ return [];
1030
+ }
1031
+ return fs4.readdirSync(directory).filter((name) => name.endsWith(".rb")).sort().map((name) => path4.join(directory, name));
1032
+ }
551
1033
  var declares = {
552
1034
  kind: "framework",
553
1035
  package: "@suss/framework-rails",
554
- dependencies: [{ ecosystem: "rubygems", name: "rails" }],
1036
+ // An app that depends on the Rails gems one at a time has railties in
1037
+ // its Gemfile and no rails.
1038
+ dependencies: [
1039
+ { ecosystem: "rubygems", name: "rails" },
1040
+ { ecosystem: "rubygems", name: "railties" }
1041
+ ],
555
1042
  reads: "Rails controller actions (Ruby), bound to the method and path `config/routes.rb` gives each one; an action the routes file does not reach is discovered with no boundary.",
556
1043
  configuration: {
557
1044
  file: "suss.rails.json",
558
- example: { root: "app", routesFile: "config/routes.rb" },
1045
+ example: {
1046
+ root: "app",
1047
+ routesFile: "config/routes.rb",
1048
+ engineRoots: ["engines/*"],
1049
+ routesFiles: ["plugins/*/plugin.rb"]
1050
+ },
559
1051
  required: false,
560
- why: "the app directory a controller is defined under and the routes file suss reads each action's method and path from. rails new scaffolds both at these paths."
1052
+ why: "the app directory a controller is defined under and the routes file suss reads each action's method and path from; rails new scaffolds both at these paths. engineRoots lists where the project keeps engines it mounts, and routesFiles any other file that adds routes, when it has either."
561
1053
  }
562
1054
  };
563
1055
  var index_default = railsFramework;