@sap/eslint-plugin-cds 2.1.1 → 2.3.2

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