@sap/eslint-plugin-cds 2.1.0 → 2.3.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.
@@ -1,15 +1,20 @@
1
+ /**
2
+ * @typedef { import("eslint").AST.SourceLocation } SourceLocation
3
+ */
4
+
1
5
  const fs = require("fs");
2
6
  const path = require("path");
3
7
  const cds = require("@sap/cds");
4
8
  const { SourceCode } = require("eslint");
5
- const { isTest } = require("./helpers");
9
+ const { isValidFile } = require("./helpers");
10
+ const { isValidEnv } = require("./validate");
6
11
 
7
12
  const cache = new Map();
8
13
 
9
14
  module.exports = {
10
15
  /**
11
- * Simple cache to store model and any cds calls made
12
- * in the rule creation api to modify the model
16
+ * Simple cache to store model and any cds calls made in the rule creation
17
+ * api to modify the model
13
18
  */
14
19
  Cache: {
15
20
  has(key) {
@@ -33,15 +38,6 @@ module.exports = {
33
38
  }
34
39
  return dump;
35
40
  },
36
- getModels() {
37
- const models = [];
38
- for (const key of cache.keys()) {
39
- if (key.startsWith("model:")) {
40
- models.push(key.replace("model:", ""));
41
- }
42
- }
43
- return models;
44
- },
45
41
  remove(key) {
46
42
  if (cache.has(key)) {
47
43
  cache.delete(key);
@@ -54,12 +50,144 @@ module.exports = {
54
50
  },
55
51
  },
56
52
 
53
+ hasModelError: function (filePath) {
54
+ if (module.exports.Cache.has(`model:${filePath}`)) {
55
+ const model = module.exports.Cache.get(`model:${filePath}`);
56
+ if (model.err) {
57
+ return true;
58
+ }
59
+ }
60
+ return false;
61
+ },
62
+
63
+ /**
64
+ * Checks whether the compiled cds model contains compilation errors which
65
+ * should only be reported via the 'cds-compile-error' rule
66
+ * @param cds cds object
67
+ * @param ruleID rule name
68
+ * @returns
69
+ */
70
+ hasCompilationError: function (context) {
71
+ const cds = context.cds;
72
+ const ruleID = context.ruleID;
73
+ if (
74
+ cds &&
75
+ cds.model &&
76
+ cds.model.err &&
77
+ cds.model.err.message.startsWith("CDS compilation failed")
78
+ ) {
79
+ if (
80
+ ruleID === "@sap/cds/cds-compile-error" ||
81
+ ruleID === "cds-compile-error"
82
+ ) {
83
+ cds.model.err;
84
+ return true;
85
+ }
86
+ }
87
+ return false;
88
+ },
89
+
90
+ /**
91
+ * Takes care of all of the cds modeling:
92
+ * - Loads the model and assigns relevant files to it
93
+ * - Updates the model (according to 'type' events in the editor)
94
+ * - Updates the ESLint configuration file path (i.e. mono-repo with
95
+ * multiple models)
96
+ * @param context
97
+ * @returns
98
+ */
99
+ populateModelAndEnv: function (context) {
100
+ // Update file and config paths
101
+ module.exports.Cache.set("filepath", context.filePath);
102
+
103
+ // Get CDS reflected model
104
+
105
+ if (
106
+ process.env["LINT_FLAVOR"] !== "parsed" &&
107
+ (module.exports.isNewFile(context.filePath) ||
108
+ module.exports.isNewConfigPath(context.configPath))
109
+ ) {
110
+ module.exports.initModel(context.configPath, context.filePath);
111
+ }
112
+ // Trigger model updates for:
113
+ // - Changed 'model' files
114
+ // - Any 'outsider' files
115
+ if (
116
+ isValidFile(context.filePath, "model") &&
117
+ module.exports.hasFileChanged(context)
118
+ ) {
119
+ module.exports.updateModel(context);
120
+ }
121
+
122
+ // Get cds environment (for internal ruleTester)
123
+ if (isValidEnv(context)) {
124
+ module.exports.Cache.set("environment", context.options[0].environment);
125
+ }
126
+ },
127
+
128
+ /**
129
+ * Checks whether a file is new or has already been
130
+ * part of an existing cds model
131
+ * @param {*} filePath
132
+ * @returns boolean
133
+ */
134
+ isNewFile: function (filePath) {
135
+ if (!module.exports.Cache.has(`model:${filePath}`)) {
136
+ return true;
137
+ } else {
138
+ return false;
139
+ }
140
+ },
141
+
142
+ /**
143
+ * Checks whether the path where the nearest ESLint configuration
144
+ * file has changed
145
+ * @param {*} configPath
146
+ * @returns boolean
147
+ */
148
+ isNewConfigPath: function (configPath) {
149
+ let update = false;
150
+ if (
151
+ !module.exports.Cache.has("pluginpath") ||
152
+ !module.exports.Cache.has("configpath") ||
153
+ configPath !== module.exports.Cache.get("configpath")
154
+ ) {
155
+ update = true;
156
+ }
157
+ // Keep track of all config paths visited
158
+ // - Used in formatter to group lint reports
159
+ // - Dirnames are used to assign any 'env' lints
160
+ if (!module.exports.Cache.has("configpaths")) {
161
+ module.exports.Cache.set("configpaths", [configPath]);
162
+ } else {
163
+ const configPaths = module.exports.Cache.get("configpaths");
164
+ configPaths.push(configPath);
165
+ module.exports.Cache.set("configpaths", configPaths);
166
+ }
167
+ return update;
168
+ },
169
+
170
+ /**
171
+ * Gets directory of the nearest ESLint config files associated
172
+ * Within this plugin, this is equivalent to the cds project's directory
173
+ * @param filePath
174
+ * @returns Directory of ESLint config file
175
+ */
176
+ loadConfigPath: function (filePath) {
177
+ let configPath = path.dirname(module.exports.getConfigPath(filePath));
178
+ if (configPath) {
179
+ module.exports.Cache.set("projectpath", configPath);
180
+ } else {
181
+ throw new Error("Failed to find an ESLint configuration file!");
182
+ }
183
+ return configPath;
184
+ },
185
+
57
186
  /**
58
187
  * Generates dummy AST with just single Program node
59
- * @param code source code
188
+ * @param code Parse file contents
60
189
  * @returns AST
61
190
  */
62
- // Types:
63
191
  getAST: function (code) {
64
192
  return {
65
193
  type: "Program",
@@ -86,13 +214,13 @@ module.exports = {
86
214
  * @param obj cds object
87
215
  * @returns Proxy for cds
88
216
  */
89
- getProxy: function (obj) {
217
+ getCDSProxy: function (obj) {
90
218
  const handler = {
91
219
  get(target, prop, receiver) {
92
220
  const value = Reflect.get(target, prop, receiver);
93
221
  if (["model", "environment"].includes(prop)) {
94
222
  if (prop === "model") {
95
- prop = `model:${module.exports.Cache.get("configpath")}`;
223
+ prop = `model:${module.exports.Cache.get("filepath")}`;
96
224
  }
97
225
  return module.exports.Cache.get(prop);
98
226
  }
@@ -147,10 +275,11 @@ module.exports = {
147
275
  },
148
276
 
149
277
  /**
150
- * Uses ESLint's static function splitLines() to split the source code text into an array of lines:
278
+ * Uses ESLint's static function splitLines() to split the source code text
279
+ * into an array of lines:
151
280
  * https://eslint.org/docs/developer-guide/nodejs-api#sourcecodesplitlines
152
281
  * Returns the index of the last line
153
- * @param {*} code
282
+ * @param code
154
283
  * @returns Last line index
155
284
  */
156
285
  getLastLine: function (code) {
@@ -163,24 +292,13 @@ module.exports = {
163
292
  return lines.length - 1;
164
293
  },
165
294
 
166
- getLastColumn: function (code, line) {
167
- let lines;
168
- if (typeof code === "string") {
169
- lines = SourceCode.splitLines(code);
170
- } else {
171
- lines = code;
172
- }
173
- return lines[line].length - 1;
174
- },
175
-
176
295
  /**
177
296
  * Generates ESlint's 'loc' from artifact string and cds $location property:
178
297
  * https://eslint.org/docs/developer-guide/working-with-rules-deprecated#contextreport
179
298
  * @param name
180
- * @param obj
299
+ * @param {SoureLocation} obj
181
300
  * @returns ESLint's 'loc' object
182
301
  */
183
- // Types: AST.SourceLocation
184
302
  getLocation: function (name, obj) {
185
303
  const loc = {
186
304
  start: { line: 0, column: 0 },
@@ -206,8 +324,8 @@ module.exports = {
206
324
  * Searches for ESLint config file types (in order or precedence)
207
325
  * and returns corresponding directory (usually project's root dir)
208
326
  * https://eslint.org/docs/user-guide/configuring#configuration-file-formats
209
- * @param currentDir start here and search until root dir
210
- * @returns dir containing ESLint config file
327
+ * @param {string} currentDir start here and search until root dir
328
+ * @returns {string} dir containing ESLint config file (empty if not exists)
211
329
  */
212
330
  getConfigPath: function (currentDir = ".") {
213
331
  const configFiles = [
@@ -229,172 +347,208 @@ module.exports = {
229
347
  }
230
348
  configDir = path.join(configDir, "..");
231
349
  }
232
- throw new Error("Failed to find an ESLint configuration file!");
350
+ return "";
233
351
  },
234
352
 
235
353
  /**
236
- * Loads LinkedCSN by:
237
- * (1) Determining config path if does not exist
238
- * (2) Running cds.load('*') to resolve full model
239
- * (3) If 2. also fails, pass on error object
240
- * @param code
241
- * @returns
354
+ * Compiles reflected model for a given project directory
355
+ * Note, that to support monorepos, the cache (in @sap/cds) must be cleared
356
+ * to also change the roots with every changed configPath.
357
+ * @param configPath
358
+ * @returns reflected model
242
359
  */
243
- loadModel: function (code = "", configPath, filePath) {
360
+ compileModelFromPath: function (configPath) {
244
361
  let compiledModel;
245
362
  let reflectedModel;
246
- if (isTest()) {
247
- if (code) {
248
- try {
249
- if (isTest()) {
250
- compiledModel = cds.compile.to.csn(code);
251
- } else {
252
- compiledModel = cds.compile.to.csn([filePath]);
253
- }
254
- if (compiledModel) {
255
- reflectedModel = cds.linked(compiledModel);
256
- }
257
- } catch (err) {
258
- reflectedModel = { err };
259
- }
260
- }
261
- } else {
262
- // Loads new model (must clear cache in order to be able to change root with every configPath)
263
- cds.resolve.cache = {};
264
- const roots = cds.resolve("*", { root: configPath });
265
- if (
266
- !module.exports.Cache.has(`model:${configPath}`) &&
267
- configPath !== filePath
268
- ) {
269
- if (roots) {
270
- try {
271
- compiledModel = cds.load(roots, {
272
- cwd: configPath,
273
- sync: true,
274
- locations: true,
275
- });
276
- if (compiledModel) {
277
- reflectedModel = cds.linked(compiledModel);
278
- }
279
- } catch (err) {
280
- reflectedModel = { err };
281
- }
363
+ cds.resolve.cache = {};
364
+ const roots = cds.resolve("*", { root: configPath });
365
+ if (roots) {
366
+ try {
367
+ compiledModel = cds.load(roots, {
368
+ cwd: configPath,
369
+ sync: true,
370
+ locations: true,
371
+ });
372
+ if (compiledModel) {
373
+ reflectedModel = cds.linked(compiledModel);
282
374
  }
283
- } else {
284
- reflectedModel = module.exports.Cache.get(`model:${configPath}`);
375
+ } catch (err) {
376
+ reflectedModel = { err };
285
377
  }
286
378
  }
287
- // Cache model files
288
- if (
289
- reflectedModel &&
290
- reflectedModel.$sources &&
291
- !module.exports.Cache.has(`modelfiles:${configPath}`)
292
- ) {
293
- const files = reflectedModel.$sources;
294
- if (files && files.length > 0) {
295
- module.exports.Cache.set(`modelfiles:${configPath}`, files);
296
- if (!isTest()) {
297
- files.forEach((file) => {
298
- module.exports.Cache.set(
299
- `file:${file}`,
300
- fs.readFileSync(file, "utf8")
301
- );
302
- });
303
- }
379
+ return reflectedModel;
380
+ },
381
+
382
+ /**
383
+ * Compiles reflected model for a dictionary of files/file contents
384
+ * Note, that this method is used to account for editor type events
385
+ * and hence, model updates.
386
+ * WARNING: Only use if cds roots are defined prior to this step
387
+ * and the dictionary is complete (the compiler will not resolve
388
+ * any missing files)!
389
+ * @param dictFiles
390
+ * @returns reflected model
391
+ */
392
+ compileModelFromDict: function (dictFiles, options) {
393
+ let reflectedModel;
394
+ try {
395
+ const compiledModel = cds.compile(dictFiles, {
396
+ sync: true,
397
+ locations: true,
398
+ ...options,
399
+ });
400
+ if (compiledModel) {
401
+ reflectedModel = cds.linked(compiledModel);
304
402
  }
403
+ } catch (err) {
404
+ reflectedModel = { err };
305
405
  }
306
- module.exports.Cache.set(`model:${configPath}`, reflectedModel);
307
- return;
406
+ return reflectedModel;
308
407
  },
309
408
 
310
409
  /**
311
- * Updates configPath (usually ESLint's configPath) for a given project if:
312
- * - File is not part of the compiled project model
313
- * It then defaults to filePath and compiles the file stand-alone
314
- * @param code
410
+ * Initiates and stores new reflected model, it's corresponding project path,
411
+ * as well as a list and dictionary of files comprising the model.
315
412
  * @param configPath
316
413
  * @param filePath
317
414
  */
318
- updateConfigPath: function (code, configPath, filePath) {
319
- let compiledModel;
320
- let reflectedModel;
321
- const files = module.exports.Cache.has(`modelfiles:${configPath}`)
322
- ? module.exports.Cache.get(`modelfiles:${configPath}`)
323
- : [];
324
- // If file is not part of cds model for this dir, it is compiled individually
325
- if (!files || !files.includes(filePath)) {
326
- module.exports.Cache.set(`configpath`, filePath);
327
- module.exports.Cache.set(`modelfiles:${filePath}`, [filePath]);
328
- module.exports.Cache.set(
329
- `file:${filePath}`,
330
- fs.readFileSync(filePath, "utf8")
331
- );
332
- if (!module.exports.Cache.has(`model:${filePath}`)) {
333
- try {
334
- if (isTest()) {
335
- compiledModel = cds.compile.to.csn(code);
336
- } else {
337
- compiledModel = cds.compile.to.csn([filePath]);
338
- }
339
- if (compiledModel) {
340
- reflectedModel = cds.linked(compiledModel);
341
- }
342
- } catch (err) {
343
- reflectedModel = { err };
344
- }
415
+ initModel: function (configPath, filePath) {
416
+ module.exports.Cache.set("configpath", configPath);
417
+ const reflectedModel = module.exports.compileModelFromPath(configPath);
418
+ let files;
419
+ if (reflectedModel && !reflectedModel.err && reflectedModel.$sources) {
420
+ module.exports.Cache.set(`model:${filePath}`, reflectedModel);
421
+ files = reflectedModel.$sources;
422
+ if (files) {
423
+ module.exports.Cache.set(`modelfiles:${configPath}`, files);
345
424
  } else {
346
- reflectedModel = module.exports.Cache.get(`model:${filePath}`);
425
+ files = [];
426
+ }
427
+ const dictFiles = module.exports.getDictFiles(configPath, files);
428
+ module.exports.Cache.set(`dictfiles:${configPath}`, dictFiles);
429
+ }
430
+ },
431
+
432
+ initModelRuleTester: function (filePath) {
433
+ const configPath = path.dirname(filePath);
434
+ module.exports.Cache.set("configpath", configPath);
435
+ let files = fs.readdirSync(configPath);
436
+ const modelfiles = [];
437
+ files.forEach((file) => {
438
+ const filePath = path.join(configPath, file);
439
+ if (isValidFile(filePath, "model")) {
440
+ modelfiles.push(filePath);
347
441
  }
442
+ });
443
+ module.exports.Cache.set(`modelfiles:${configPath}`, files);
444
+ const dictFiles = module.exports.getDictFiles(configPath, modelfiles);
445
+ module.exports.Cache.set(`dictfiles:${configPath}`, dictFiles);
446
+ const compiledModel = module.exports.compileModelFromDict(dictFiles);
447
+ let reflectedModel;
448
+ if (compiledModel) {
449
+ reflectedModel = cds.linked(compiledModel);
450
+ }
451
+ if (reflectedModel) {
452
+ module.exports.Cache.set(`model:${filePath}`, reflectedModel);
348
453
  }
349
- module.exports.Cache.set(`model:${filePath}`, reflectedModel);
350
454
  },
351
455
 
352
456
  /**
353
- * Updates compiled model (CSN) by:
354
- * (1) Getting model files from CSN.$sources (cached)
355
- * (2) Running compile.to.csn with updated sources dictionary
356
- * @param code
457
+ * Creates or updates a dictionary of files/file contents for a given
458
+ * project path.
459
+ * @param configPath
460
+ * @param files
461
+ * @returns dictFiles
357
462
  */
358
- updateModel: function (code, configPath) {
359
- let compiledModel;
360
- let reflectedModel;
361
- let files = [];
362
- const dictFiles = {};
363
- if (module.exports.Cache.has(`modelfiles:${configPath}`)) {
364
- files = module.exports.Cache.get(`modelfiles:${configPath}`);
365
- if (files.length > 1) {
366
- files.forEach((file) => {
367
- if (module.exports.Cache.has(`file:${file}`)) {
368
- dictFiles[file] = module.exports.Cache.get(`file:${file}`);
369
- } else {
370
- dictFiles[file] = fs.readFileSync(file, "utf8");
371
- }
372
- });
373
- try {
374
- /** Ignore typings here as the options 'sync' and 'cwd'
375
- * should not be visible in the public api! */
376
- compiledModel = cds.compile.to.csn(dictFiles, {
377
- sync: true,
378
- locations: true,
379
- });
380
- if (compiledModel) {
381
- reflectedModel = cds.linked(compiledModel);
382
- }
383
- } catch (err) {
384
- reflectedModel = { err };
385
- }
386
- } else if (files.length === 1) {
387
- try {
388
- compiledModel = cds.compile.to.csn(code);
389
- if (compiledModel) {
390
- reflectedModel = cds.linked(compiledModel);
391
- }
392
- } catch (err) {
393
- reflectedModel = { err };
463
+ getDictFiles: function (configPath, files=[]) {
464
+ let dictFiles = {};
465
+ if (module.exports.Cache.has(`dictfiles:${configPath}`)) {
466
+ dictFiles = module.exports.Cache.get(`dictfiles:${configPath}`);
467
+ } else {
468
+ files.forEach((file) => {
469
+ if (module.exports.Cache.has(`file:${file}`)) {
470
+ dictFiles[file] = module.exports.Cache.get(`file:${file}`);
471
+ } else {
472
+ dictFiles[file] = fs.readFileSync(file, "utf8");
394
473
  }
474
+ });
475
+ }
476
+ return dictFiles;
477
+ },
478
+
479
+ /**
480
+ * Determines whether an incoming file has changed contents
481
+ * @param context cds context object
482
+ * @returns boolean
483
+ */
484
+ hasFileChanged: function (context) {
485
+ const files = module.exports.Cache.get(`modelfiles:${context.configPath}`);
486
+ const dictFiles = module.exports.getDictFiles(context.configPath, files);
487
+ // If incoming file is a 'model' file
488
+ if (module.exports.isFileInModel(context, files)) {
489
+ // Only update on detected changes
490
+ if (dictFiles[context.filePath] !== context.code) {
491
+ dictFiles[context.filePath] = context.code;
492
+ module.exports.Cache.set(`dictfiles:${context.configPath}`, dictFiles);
493
+ return true;
494
+ }
495
+ } else {
496
+ if (dictFiles[context.filePath] !== context.code) {
497
+ return true;
395
498
  }
396
- module.exports.Cache.set(`model:${configPath}`, reflectedModel);
397
499
  }
398
- return;
500
+ return false;
501
+ },
502
+
503
+ /**
504
+ * Checks whether a file is part of the model for a given project
505
+ * @param context
506
+ * @param files
507
+ * @returns boolean
508
+ */
509
+ isFileInModel(filePath, files) {
510
+ if (files && files.length > 0 && files.includes(filePath)) {
511
+ return true;
512
+ }
513
+ return false;
514
+ },
515
+
516
+ /**
517
+ * Updates and stores reflected model on file changes. Model compilation
518
+ * us handled separately for 'model' files (part of model) and 'outsider'
519
+ * files.
520
+ * @param context cds context object
521
+ */
522
+ updateModel: function (context) {
523
+ let reflectedModel;
524
+ let files = module.exports.Cache.get(`modelfiles:${context.configPath}`);
525
+ if (!files) {
526
+ files = [];
527
+ }
528
+ // If incoming file is a 'model' file
529
+ if (
530
+ !process.env["LINT_FLAVOR"] === "parsed" ||
531
+ module.exports.isFileInModel(context, files)
532
+ ) {
533
+ const dictFiles = module.exports.Cache.get(
534
+ `dictfiles:${context.configPath}`
535
+ );
536
+ dictFiles[context.filePath] = context.code;
537
+ reflectedModel = module.exports.compileModelFromDict(dictFiles, {
538
+ flavor: "inferred",
539
+ });
540
+ files.forEach((file) => {
541
+ module.exports.Cache.set(`model:${file}`, reflectedModel);
542
+ });
543
+ } else {
544
+ // If incoming file is an 'outsider' file
545
+ const dictFiles = {};
546
+ dictFiles[context.filePath] = context.code;
547
+ let flavor = "parsed";
548
+ reflectedModel = module.exports.compileModelFromDict(dictFiles, {
549
+ flavor,
550
+ });
551
+ module.exports.Cache.set(`model:${context.filePath}`, reflectedModel);
552
+ }
399
553
  },
400
554
  };