@sap/eslint-plugin-cds
Version:
ESLint plugin including recommended SAP Cloud Application Programming model and environment rules
123 lines (111 loc) • 3.92 kB
JavaScript
'use strict'
const cds = require('@sap/cds')
const { basename, extname } = require('node:path')
const findFuzzy = require('../utils/findFuzzy')
const { RULE_CATEGORIES } = require('../constants')
const SEP = '[,;\t]'
const EOL = '\\r?\\n'
module.exports = {
meta: {
schema: [{/* to avoid deprecation warning for ESLint 9 */}],
docs: {
description: 'CSV files for entities must refer to valid element names.',
category: RULE_CATEGORIES.csv,
recommended: true,
url: 'https://cap.cloud.sap/docs/tools/cds-lint/rules/valid-csv-header',
},
severity: 'warn',
type: 'problem',
hasSuggestions: true,
messages: {
InvalidColumn: "Invalid column '{{column}}'. Did you mean '{{candidates}}'?",
ReplaceColumnWith: "Replace '{{column}}' with '{{candidates}}'"
},
model: 'inferred'
},
create: function (context) {
return checkValidHeaders
function checkValidHeaders () {
const filePath = context.getFilename()
const sourcecode = context.getSourceCode()
const code = sourcecode.getText()
let model = context.getModel()
if (!filePath.endsWith('.csv')) return
if (!model) return
try {
model = cds.compile.for.sql(model, { names: cds.env.sql.names, messages: [] })
} catch(e) {
// ignore invalid models; the compiler emits errors already
if (e.code !== 'ERR_CDS_COMPILATION_FAILURE')
throw e
}
if (!model) return
const filename = basename(filePath)
const entityName = filename.replace(/-/g, '.').slice(0, -extname(filename).length)
const entity = _entity4(entityName, model)
if (!entity) return
const elements = Object.values(entity.elements)
.filter(e => !!e['@cds.persistence.name'])
.map(e => e['@cds.persistence.name'].toUpperCase())
const [cols] = cds.parse.csv(code)
const missing = cols.filter(col => !elements.includes(col.toUpperCase()))
for (const miss of missing) {
const index = _findInCode(miss, code)
const loc = sourcecode.getLocFromIndex(index)
const candidates = findFuzzy(miss, Object.keys(entity.elements).sort())
const suggest = candidates.map(cand => {
return {
messageId: 'ReplaceColumnWith',
data: { column: miss, candidates: cand },
fix: fixer => fixer.replaceTextRange([index, index + miss.length], cand)
}
})
context.report({
messageId: 'InvalidColumn',
data: { column: miss, candidates },
loc: { start: loc, end: { line: loc.line, column: loc.column + miss.length } },
file: filePath,
suggest
})
}
}
}
}
/**
* @param {string} needle
* @param {string} code
* @returns {number} -1 if not found
*/
function _findInCode(needle, code) {
// middle
let match = new RegExp(SEP + needle + SEP).exec(code)
if (match) return match.index + 1
// end of line
match = new RegExp(SEP + needle + EOL).exec(code)
if (match) return match.index + 1
// start of doc
match = new RegExp('^' + needle + SEP).exec(code)
if (match) return match.index
// somewhere (fallback)
return code.indexOf(needle)
}
/**
* @param {string} name
* @param {object} csn
*/
function _entity4 (name, csn) {
const entity = csn.definitions[name]
if (!entity) {
if (/(.+)[._]texts_?/.test(name)) {
// 'Books.texts', 'Books.texts_de'
const base = csn.definitions[RegExp.$1]
return base && _entity4(base.elements.texts.target, csn)
} else return
}
// we also support simple views if they have no projection
const p = (entity.query && entity.query.SELECT) || entity.projection
if (p && !p.columns && p.from.ref && p.from.ref.length === 1) {
if (csn.definitions[p.from.ref[0]]) return entity
}
return entity.name ? entity : { name, __proto__: entity }
}