UNPKG

@sap/cds-compiler

Version:

CDS (Core Data Services) compiler and backends

1,408 lines (1,281 loc) 68.7 kB
// Functions and classes for syntax messages // See internalDoc/ReportingMessages.md and lib/base/message-registry.js for details. 'use strict'; const { term } = require('../utils/term'); const { Location, locationString } = require('./location'); const { isBetaEnabled } = require('./model'); const { centralMessages, configurableForValidValues, centralMessageTexts, oldMessageIds, } = require('./message-registry'); const _messageIdsWithExplanation = require('../../share/messages/message-explanations.json').messages; const { analyseCsnPath, traverseQuery } = require('../model/csnRefs'); const { CompilerAssertion } = require('./error'); const { getArtifactName } = require('../compiler/base'); const { cdlNewLineRegEx } = require('../language/textUtils'); const meta = require('./meta'); const fs = require('fs'); const path = require('path'); const { inspect } = require('util'); // term instance for messages const colorTerm = term(); // Functions ensuring message consistency during runtime with --test-mode let test$severities = null; let test$texts = null; /** * Returns true if at least one of the given messages is of severity "Error". * * @param {CompileMessage[]} messages * @returns {boolean} */ function hasErrors( messages ) { return messages && messages.some( m => m.severity === 'Error' ); } /** * Returns true if at least one of the given messages is of severity "Error" * and *cannot* be reclassified to a warning for the given module. * Won't detect already downgraded messages. * * @param {CompileMessage[]} messages * @param {string} moduleName * @param {CSN.Options} options * @returns {boolean} */ function hasNonDowngradableErrors( messages, moduleName, options ) { return messages && messages.some( m => m.severity === 'Error' && !isDowngradable( m.messageId, moduleName, options )); } /** * Returns true if the given message id exist in the central message register and is * downgradable * - Non-errors are not downgradable if the moduleName is listed in the * `errorFor` property * - Errors might be downgradable according to the `configurableFor` property, * * @param {string} messageId * @param {string} moduleName * @param {CSN.Options} options Options used to check for test mode and beta flags. * @returns {boolean} */ function isDowngradable( messageId, moduleName, options ) { if (!messageId || !centralMessages[messageId]) return false; const msg = centralMessages[messageId]; // errorFor has the highest priority. If the message is an error for // the module, it is NEVER downgradable. if (msg.severity !== 'Error') return !msg.errorFor?.includes( moduleName ); const { configurableFor } = msg; return (Array.isArray( configurableFor )) ? configurableFor.includes( moduleName ) : configurableFor && configurableForValidValues[configurableFor]?.( options ); } /** * Returns a marker for messages strings indicating whether the message can be downgraded * or whether it will be an error in the next cds-compiler release. * * @returns {string} */ function severityChangeMarker( msg, config ) { if (config.moduleForMarker && msg.messageId) { if (!msg.severity || msg.severity === 'Error') { if (isDowngradable( msg.messageId, config.moduleForMarker, { deprecated: { downgradableErrors: true } })) // Do not include testMode here, no marker for configurableFor: 'test' return '‹↓›'; } else if (centralMessages[msg.messageId]?.errorFor?.includes( 'v7' )) { return '‹↑›'; } } return ''; } /** * Class for combined compiler errors. Additional members: * - `messages`: array of compiler messages (CompileMessage) * - `model`: the CSN model */ class CompilationError extends Error { /** * @param {CompileMessage[]} messages * @param {XSN.Model} [model] the XSN model, only to be set with options.attachValidNames */ constructor(messages, model) { // Because test frameworks such as mocha and jest to not call `toString()` on // an unhandled CompilationError and instead use `e.stack` directly, there is // no proper message about _what_ the root cause of the exception was. // To mitigate that, we serialize the first error in the message as well. const firstError = messages.find( m => m.severity === 'Error' )?.toString() || ''; super( `CDS compilation failed (@sap/cds-compiler v${ meta.version() })\n${ firstError }` ); /** @since v4.0.0 */ this.code = 'ERR_CDS_COMPILATION_FAILURE'; this.messages = [ ...messages ].sort(compareMessageSeverityAware); // property `model` is only set with options.attachValidNames: Object.defineProperty( this, 'model', { value: model || undefined, configurable: true } ); } /** * Called by `console.*()` functions in NodeJs. To avoid `err.messages` being * printed using `util.inspect()`. * * @return {string} */ [inspect.custom]() { return this.stack || this.message; } /** * Called when the exception is printed, e.g. when it is not caught. * To give users a bit of information what went wrong, return stringified * error messages. But only errors to avoid spamming users. * * Compiler consumers should catch compilation errors and properly handle * them by printing messages themselves. * * @returns {string} */ toString() { let messages = [ 'CDS compilation failed' ]; if (this.messages) { messages = messages.concat(this.messages .filter(msg => msg.severity === 'Error') .map( m => m.toString())); } return messages.join('\n'); } /** * @deprecated Use `.messages` instead. */ get errors() { return this.messages; } } /** * Class for individual compile message. * * @class CompileMessage */ class CompileMessage { /** * Creates an instance of CompileMessage. * @param {CSN.Location} location Location of the message * @param {string} msg The message text * @param {MessageSeverity} [severity='Error'] Severity: Debug, Info, Warning, Error * @param {string} [id] The ID of the message - visible as property messageId * @param {any} [home] * @param {string} [moduleName] Name of the module that created this message * * @memberOf CompileMessage */ constructor(location, msg, severity = 'Error', id = null, home = null, moduleName = null) { this.message = msg; this.$location = { __proto__: Location.prototype, ...location, address: undefined }; this.validNames = null; this.home = home; // semantic location, e.g. 'entity:"E"/element:"x"' this.severity = severity; this.messageId = id; Object.defineProperty( this, '$module', { value: moduleName, configurable: true } ); // Uncomment when running TypeScript linter // this.messageId = id; // this.$module = moduleName; // this.error = null; } toString() { // Used by cds-dk in their own `toString` wrapper. return messageString( this, { normalizeFilename: false, noMessageId: true, // no message-id before finalization! noHome: false, module: null, }); } } const severitySpecs = { error: { name: 'Error', level: 0 }, warning: { name: 'Warning', level: 1 }, info: { name: 'Info', level: 2 }, debug: { name: 'Debug', level: 3 }, }; /** * Get the reclassified severity of the given message using: * * 1. The specified severity: either centrally provided or via the input severity * - when generally specified as 'Error', immediately return 'Error' * if message is not specified as configurable (for the given module name) * - when generally specified otherwise, immediately return 'Error' * if message is specified as being an error for the given module name * 2. User severity wishes in option `severities`: when provided and no 'Error' has * been returned according to 1, return the severity according to the user wishes. * 3. Otherwise, use the specified severity. * * @param {object} msg The CompileMessage. * @param {CSN.Options} options * @param {string} moduleName * @returns {MessageSeverity} */ function reclassifiedSeverity( msg, options, moduleName ) { const spec = centralMessages[msg.messageId] || { severity: msg.severity, configurableFor: null, errorFor: null }; let { severity } = spec; if (spec.severity === 'Error') { if (!isDowngradable(msg.messageId, moduleName, options)) return 'Error'; } else { const { errorFor } = spec; if (Array.isArray( errorFor )) { if (errorFor.includes(moduleName)) return 'Error'; if (errorFor.includes('v7') && isBetaEnabled(options, 'v7preview')) { severity = 'Error'; if (!isDowngradable(msg.messageId, moduleName, options)) return severity; } } } if (!options.severities) return severity; let newSeverity = options.severities[msg.messageId]; // The user could have specified a severity through an old message ID. if (!newSeverity && spec.oldNames) { const oldName = spec.oldNames.find((name => options.severities[name])); newSeverity = options.severities[oldName]; } return normalizedSeverity( newSeverity ) || severity; } function normalizedSeverity( severity ) { if (typeof severity !== 'string') return (severity == null) ? null : 'Error'; const s = severitySpecs[severity.toLowerCase()]; return s ? s.name : 'Error'; } /** * Compare two severities. Returns 0 if they are the same, and <0 if * `a` has a lower `level` than `b` according to {@link severitySpecs}, * where "lower" means: comes first when sorted. * * compareSeverities('Error', 'Info') => Error < Info => -1 * * @param {MessageSeverity} a * @param {MessageSeverity} b * @see severitySpecs */ function compareSeverities( a, b ) { // default: low priority const aSpec = severitySpecs[a.toLowerCase()] || { level: 10 }; const bSpec = severitySpecs[b.toLowerCase()] || { level: 10 }; return aSpec.level - bSpec.level; } /** * Find the nearest $location for the given CSN path in the model. * If the path does not exist, the parent is used, and so on. * * @param {CSN.Model} model * @param {CSN.Path} csnPath * @returns {CSN.Location | null} */ function findNearestLocationForPath( model, csnPath ) { if (!model) return null; let lastLocation = null; /** @type {object} */ let currentStep = model; for (const step of csnPath) { if (!currentStep) return lastLocation; currentStep = currentStep[step]; if (currentStep && currentStep.$location) lastLocation = currentStep.$location; } return lastLocation; } /** * Create the `message` functions to emit messages. * * @example * ```js * const { createMessageFunctions } = require(‘../base/messages’); * function module( …, options ) { * const { message, info, throwWithError } = createMessageFunctions( options, moduleName ); * // [...] * message( 'message-id', <location>, <text-arguments>, <severity>, <text> ); * info( 'message-id', <location>, [<text-arguments>,] <text> ); * // [...] * throwWithError(); * } * ``` * @param {CSN.Options} [options] * @param {string} [moduleName] * @param {object} [model=null] the CSN or XSN model, used for convenience */ function createMessageFunctions( options, moduleName, model = null ) { return makeMessageFunction( model, options, moduleName ); } /** * Create the `message` function to emit messages. * * @example * ```js * const { makeMessageFunction } = require(‘../base/messages’); * function module( …, options ) { * const { message, info, throwWithError } = makeMessageFunction( model, options, moduleName ); * // [...] * message( 'message-id', <location>, <text-arguments>, <severity>, <text> ); * info( 'message-id', <location>, [<text-arguments>,] <text> ); * // [...] * throwWithError(); * } * ``` * @param {object} model * @param {CSN.Options} [options] * @param {string|null} [_moduleName] */ function makeMessageFunction( model, options, _moduleName = null ) { let moduleName = _moduleName; if (options.testMode) { // ensure message consistency during runtime with --test-mode _check$Init( options ); if (!options.messages) throw new CompilerAssertion('makeMessageFunction() expects options.messages to exist in testMode!'); } const hasMessageArray = Array.isArray(options.messages); /** * Array of collected compiler messages. Only use it for debugging. Will not * contain the messages created during a `callTransparently` call. * * @type {CompileMessage[]} */ let messages = hasMessageArray ? options.messages : []; /** * Whether an error was emitted in the module. Also includes reclassified errors. * @type {boolean} */ let hasNewError = false; reclassifyMessagesForModule(); return { message, error, warning, info, debug, messages, throwWithError, throwWithAnyError, callTransparently, moduleName, setModel, setModuleName, setOptions, }; function _message( id, location, textOrArguments, severity, texts = null ) { _validateFunctionArguments(id, location, textOrArguments, severity, texts); // Special case for _info, etc.: textOrArguments may be a string. if (typeof textOrArguments === 'string') { texts = { std: textOrArguments }; textOrArguments = {}; } const [ fileLocation, semanticLocation, definition ] = _normalizeMessageLocation(location); const text = messageText( texts || centralMessageTexts[id], textOrArguments ); /** @type {CompileMessage} */ const msg = new CompileMessage( fileLocation, text, severity, id, semanticLocation, moduleName ); if (options.internalMsg) msg.error = new Error( 'stack' ); if (definition) msg.$location.address = { definition }; // TODO: remove if (id) { if (options.testMode && !options.$recompile) _check$Consistency( id, moduleName, severity, texts, options ); msg.severity = reclassifiedSeverity( msg, options, moduleName ); } messages.push( msg ); hasNewError = hasNewError || msg.severity === 'Error' && !(options.testMode && isDowngradable( msg.messageId, moduleName, options )); if (!hasMessageArray) console.error( messageString( msg ) ); // eslint-disable-line no-console return msg; } /** * Validate the arguments for the message() function. This is needed during the transition * to the new makeMessageFunction(). */ function _validateFunctionArguments( id, location, textArguments, severity, texts ) { if (!options.testMode) return; if (id !== null && typeof id !== 'string') _expectedType('id', id, 'string'); if (location !== null && location !== undefined && !Array.isArray(location) && typeof location !== 'object') _expectedType('location', location, 'XSN/CSN location, CSN path'); if (severity != null && typeof severity !== 'string') _expectedType('severity', severity, 'string'); const isShortSignature = (typeof textArguments === 'string'); // textArguments => texts if (isShortSignature) { if (texts) throw new CompilerAssertion('No "texts" argument expected because text was already provided as third argument.'); } else { if (textArguments !== undefined && typeof textArguments !== 'object') _expectedType('textArguments', textArguments, 'object'); if (texts !== undefined && typeof texts !== 'object' && typeof texts !== 'string') _expectedType('texts', texts, 'object or string'); } function _expectedType( field, val, type ) { throw new CompilerAssertion(`Invalid argument type for ${ field }! Expected ${ type } but got ${ typeof val }. Do you use the old function signature?`); } } /** * Normalize the given location. Location may be a CSN path, XSN/CSN location or an * array of the form `[CSN.Location, XSN user, suffix]`. * TODO: normalize to [ Location, SemanticLocation ] * * @param {any} location * @returns {[CSN.Location, string, string]} Location, semantic location and definition. */ function _normalizeMessageLocation( location ) { if (!location) // e.g. for general messages unrelated to code return [ null, null, null ]; if (typeof location === 'object' && !Array.isArray(location)) // CSN.Location (with line/endLine, col/endCol) return [ location, location.home || null, null ]; const isCsnPath = (typeof location[0] === 'string'); // could be `definitions`, `extensions`, .... if (isCsnPath) { return [ findNearestLocationForPath( model, location ), constructSemanticLocationFromCsnPath( model, options, location ), location[1], // location[0] is 'definitions' ]; } if (location[1]?.mainKind) return [ location[0], location[1].toString(), null ]; let semanticLocation = location[1] ? homeName( location[1], false ) : null; if (location[2]) { // optional suffix, e.g. annotation semanticLocation += `/${ (typeof location[2] === 'string') ? location[2] : homeName(location[2]) }`; } const definition = location[1] ? homeName( location[1], true ) : null; // If no XSN location is given, check if we can use the one of the artifact let fileLocation = location[0]; if (!fileLocation && location[1]) fileLocation = location[1].location || location[1].$location || null; return [ fileLocation, semanticLocation, definition ]; } /** * Create a compiler message for model developers. * * @param {string} id Message ID * @param {[CSN.Location, XSN.Artifact]|CSN.Path|CSN.Location|CSN.Location} location * Either a (XSN/CSN-style) location, a tuple of file location * and "user" (address) or a CSN path a.k.a semantic location path. * @param {object} [textArguments] Text parameters that are replaced in the texts. * @param {string|object} [texts] */ function message( id, location, textArguments = null, texts = null ) { if (!id) throw new CompilerAssertion('A message id is missing!'); if (!centralMessages[id]) throw new CompilerAssertion(`Message id '${ id }' is missing an entry in the central message register!`); return _message(id, location, textArguments, null, texts); } /** * Create a compiler error message. * @see message() */ function error( id, location, textOrArguments = null, texts = null ) { return _message(id, location, textOrArguments, 'Error', texts); } /** * Create a compiler warning message. * @see message() */ function warning( id, location, textOrArguments = null, texts = null ) { return _message(id, location, textOrArguments, 'Warning', texts); } /** * Create a compiler info message. * @see message() */ function info( id, location, textOrArguments = null, texts = null ) { return _message(id, location, textOrArguments, 'Info', texts); } /** * Create a compiler debug message (usually not shown). * @see message() */ function debug( id, location, textOrArguments = null, texts = null ) { return _message(id, location, textOrArguments, 'Debug', texts); } function throwWithError() { if (hasNewError) throw new CompilationError(messages, options.attachValidNames && model); } /** * Throws a CompilationError exception if there is at least one error message * in the model's messages after reclassifying existing messages according to * the module name. * If `--test-mode` is enabled, this function will only throw if the * error *cannot* be downgraded to a warning. This is done to ensure that * developers do not rely on certain errors leading to an exception. */ function throwWithAnyError() { if (!messages || !messages.length) return; const hasError = options.testMode ? hasNonDowngradableErrors : hasErrors; if (hasError( messages, moduleName, options )) throw new CompilationError(messages, options.attachValidNames && model); } /** * Reclassifies all messages according to the current module. * This is required because if throwWithError() throws and the message's * severities has `errorFor` set, then the message may still appear to be a warning. */ function reclassifyMessagesForModule() { for (const msg of messages) { if (msg.messageId && msg.severity !== 'Error') { const severity = reclassifiedSeverity( msg, options, moduleName ); if (severity !== msg.severity) { msg.severity = severity; // Re-set the module regardless of severity, since we reclassified it. Object.defineProperty( msg, '$module', { value: moduleName, configurable: true } ); hasNewError = hasNewError || severity === 'Error' && !(options.testMode && isDowngradable( msg.messageId, moduleName, options )); } } } } /** * Collects all messages during the call of the callback function instead of * storing them in the model. Returns the collected messages. * Not yet in use. * * @param {Function} callback * @param {...any} args * @returns {CompileMessage[]} */ function callTransparently( callback, ...args ) { const backup = messages; messages = []; callback(...args); const collected = messages; messages = backup; return collected; } /** * Change the model used to calculate CSN locations. * This is necessary if you change the model heavily and rely on $paths relative to the new model. * * @param {CSN.Model} _model */ function setModel( _model ) { model = _model; } /** * Change the moduleName used for reclassifying messages. * Needed for to.sql.migration + script * * @param {string} __moduleName */ function setModuleName( __moduleName ) { moduleName = __moduleName; } /** * Change the options used to determine message severities. * This is necessary if you change `options.severities`, as otherwise they may not be picked up. * * @param {CSN.Options} _options */ function setOptions( _options ) { options = _options; } } /** * Perform message consistency check during runtime with --test-mode */ function _check$Init( options ) { if (!test$severities && !options.severities) test$severities = Object.create(null); if (!test$texts) { test$texts = Object.create(null); for (const [ id, texts ] of Object.entries( centralMessageTexts )) test$texts[id] = (typeof texts === 'string') ? { std: texts } : { ...texts }; } } /** * Check the consistency of the given message and run some basic lint checks. These include: * * - Long message IDs must be listed centrally. * - Messages with the same ID must have the same severity (in a module). * - Messages with the same ID must have the same message texts. * This ensures that $(PLACEHOLDERS) are used and that we don't accidentally * use the same ID for different meanings, i.e. texts. * * @param {string} id * @param {string} moduleName * @param {string} severity * @param {string|object} texts * @param {CSN.Options} options * @private */ function _check$Consistency( id, moduleName, severity, texts, options ) { // TODO: replace by linter? if (id.length > 32 && !centralMessages[id]) throw new CompilerAssertion( `The message ID "${ id }" has more than 30 chars and must be listed centrally` ); if (!options.severities) _check$Severities( id, moduleName || '?', severity ); for (const [ variant, text ] of Object.entries( (typeof texts === 'string') ? { std: texts } : texts || {} )) _check$Texts( id, variant, text ); } /** * Check the consistency of the message severity for the given message ID. * Messages with the same ID must have the same severity (in a module). * Non-downgradable errors must never be called with a lower severity. * * @param {string} id * @param {string} moduleName * @param {string} severity * @private */ function _check$Severities( id, moduleName, severity ) { if (!severity) // if just used message(), we are automatically consistent return; const spec = centralMessages[id]; if (!spec) { const expected = test$severities[id]; if (!expected) test$severities[id] = severity; else if (expected !== severity) throw new CompilerAssertion( `Inconsistent severity: Expecting "${ expected }" from previous call, not "${ severity }" for message ID "${ id }"` ); return; } // now try whether the message could be something less than an Error in the module due to user wishes if (!isDowngradable(id, moduleName, { testMode: true, deprecated: { downgradableErrors: true } } )) { // always an error in module if (severity !== 'Error') throw new CompilerAssertion( `Inconsistent severity: Expecting "Error", not "${ severity }" for message ID "${ id }" in module "${ moduleName }"` ); } else if (spec.severity === 'Error') { throw new CompilerAssertion( `Inconsistent severity: Expecting the use of function message() when message ID "${ id }" is a configurable error in module "${ moduleName }"` ); } else if (spec.severity !== severity) { throw new CompilerAssertion( `Inconsistent severity: Expecting "${ spec.severity }", not "${ severity }" for message ID "${ id }" in module "${ moduleName }"` ); } } /** * Check the consistency of the message text for the given message ID. * * Messages with the same ID must have the same message texts. * This ensures that $(PLACEHOLDERS) are used and that we don't accidentally * use the same ID for different meanings, i.e. texts. * * @param {string} id * @param {string} prop * @param {string} val * @private */ function _check$Texts( id, prop, val ) { if (!test$texts[id]) test$texts[id] = Object.create(null); const expected = test$texts[id][prop]; if (!expected) test$texts[id][prop] = val; else if (expected !== val) throw new CompilerAssertion( `Different texts for the same message ID. Expecting “${ expected }”, not “${ val }” for ID “${ id }” and text variant “${ prop }”`); } const quote = { // could be an option in the future double: p => `“${ p }”`, // for names, including annotation names (with preceding `@`) single: p => `‘${ p }’`, // for other things cited from or expected in the model angle: p => `‹${ p }›`, // for tokens like ‹Identifier›, and similar direct: p => p, // e.g. for numbers _not cited from or expected in_ the source upper: p => p.toUpperCase(), }; const paramsTransform = { // simple convenience: name: quote.double, id: quote.double, alias: quote.double, anno, annos: transformManyWith( anno ), delimited: n => quote.single( asDelimitedId(n) ), file: quote.single, option: quote.single, prop: quote.single, siblingprop: quote.single, parentprop: quote.single, subprop: quote.single, otherprop: quote.single, code: quote.single, enum: sym => quote.single( `#${ sym }`), newcode: quote.single, kind: quote.single, meta: quote.angle, othermeta: quote.angle, keyword, module: quote.single, // more complex convenience: names: transformManyWith( quoted ), number: quote.single, // number cited from source or expected in source location: ({ line, col }) => `${ line }:${ col }`, count: quote.direct, line: quote.direct, col: quote.direct, literal: quote.direct, n: quote.direct, m: quote.direct, value, rawvalue: quote.single, rawvalues: transformManyWith( quote.single ), // no 'double' quotes for strings othervalue: value, art: transformArg, service: transformArg, sorted_arts: transformManyWith( transformArg, true ), target: transformArg, source: transformArg, elemref: transformElementRef, type: transformArg, othertype: transformArg, offending: tokenSymbol, op: quote.single, expecting: transformManyWith( tokenSymbol ), // msg: m => m, $reviewed: ignoreTextTransform, version: quote.single, // TODO delete: just use for OData $(VERSION), with version: 2.0 }; function asDelimitedId( id ) { // Same as in toCdl, but we don't want cyclic dependencies to toCdl. return `![${ id.replace(/]/g, ']]') }]`; } function anno( name ) { return (name.charAt(0) === '@') ? quote.double( name ) : quote.double( `@${ name }` ); } function value( val ) { switch (typeof val) { case 'number': case 'boolean': case 'bigint': case 'undefined': { return quote.single( val ); } case 'string': { // TODO: should we also shorten the string if too long? TODO: false, true, null? return (!val || Number.parseFloat( val ).toString() === val || // with quotes (TODO: use `…` with escape chars): /'/.test( val )) // sync ') ? quote.single( `'${ val.replace(/'/g, '\'\'') }'` ) : quote.single( val ); } case 'object': { return (val) ? quote.angle( Array.isArray( val ) ? 'array' : 'object' ) : quote.single( val ); } default: return quote.angle( typeof val ); } } const keywordRepresentations = { association: 'Association', composition: 'Composition', }; function keyword( val ) { const v = val.toLowerCase(); return quote.single( keywordRepresentations[v] || v ); } function ignoreTextTransform() { return null; } function transformManyWith( t, sorted ) { return function transformMany( many, r, args, texts ) { const prop = [ 'none', 'one', 'two' ][many.length]; const names = many.map(t); if (sorted) names.sort(); if (!prop || !texts[prop] || args['#'] ) return names.join(', '); r['#'] = prop; // text variant if (many.length === 2) r.second = names[1]; return many.length && names[0]; }; } /** * Quote the given string. Performs a type sanity check. * * @param {string} name * @return {string} */ function quoted( name ) { if (typeof name === 'string') return quote.double( name ); throw new CompilerAssertion( `Expecting a string, not ${ typeof name } (${ JSON.stringify(name) })` ); } function tokenSymbol( token ) { if (token.match( /^[A-Z][A-Z]/ )) // keyword return keyword( token ); else if (token.match( /^[A-Z][a-z]/ )) // Number, Identifier, ... return quote.angle( token ); if (token.startsWith('\'') && token.endsWith('\'')) // operator token symbol return quote.single( token.slice( 1, -1 )); else if (token === '<EOF>') return quote.angle( 'EOF' ); return quote.single( token ); // should not happen } /** * Transform an element reference (/path), e.g. on-condition path. */ function transformElementRef( arg ) { const ref = arg.ref || arg.path; if (!ref) return quoted( arg ); // Can be used by CSN backends or compiler to create a simple path such as E:elem return quoted( pathToMessageString( arg ) ); } function pathToMessageString( arg ) { const ref = arg?.ref || arg?.path || arg; // support CSN and XSN if (!ref) return null; return ((arg.scope === 'param' || arg.param) ? ':' : '') + ref.map( item => (typeof item !== 'string' ? `${ item.id }${ item.args ? '(…)' : '' }${ item.where ? '[…]' : '' }` : item) ).join('.'); } function transformArg( arg, r, args, texts ) { if (!arg || typeof arg !== 'object') return quoted( arg ); if (arg._artifact) arg = arg._artifact; while (arg._outer) // nested 'items' arg = arg._outer; if (args['#'] || args.member ) return shortArtName( arg ); if (arg.ref) { // Can be used by CSN backends to create a simple path such as E:elem if (arg.ref.length > 1) return quoted(`${ pathId(arg.ref[0]) }:${ arg.ref.slice(1).map(pathId).join('.') }`); return quoted(pathId(arg.ref[0])); } if (!arg.name) return quoted( arg.name ); const name = getArtifactName( arg ); const prop = [ 'element', 'param', 'action', 'alias' ].find( p => name[p] ); // if (!prop) throw Error() if (!prop || !texts[prop] ) return shortArtName( arg ); r['#'] = texts[name.$variant] && name.$variant || prop; // text variant (set by searchName) r.member = quoted( name[prop] ); return artName( arg, prop ); } function pathId( item ) { return (typeof item === 'string') ? item : item.id; } // TODO: very likely delete this function function searchName( art, id, variant ) { if (!variant) { // used to mention the "effective" type in the message, not the // originally provided one (TODO: mention that in the message text) const type = art._effectiveType && art._effectiveType.kind !== 'undefined' ? art._effectiveType : art; if (type.elements) { // only mentioned elements art = type.target?._artifact || type; variant = 'element'; } else { variant = 'absolute'; } } if (variant === 'absolute') { const absolute = `${ art.name.id }.${ id }`; return { kind: art.kind, name: { id: absolute, $variant: variant }, }; } const undef = { kind: variant || art.kind, name: { id, $variant: variant }, }; Object.defineProperty( undef, '_parent', { value: art, configurable: true, writable: true } ); Object.defineProperty( undef, '_main', { value: art._main || art, configurable: true, writable: true } ); // console.log('SN:',undef) return undef; } function messageText( texts, params, transform ) { if (typeof texts === 'string') texts = { std: texts }; const args = {}; for (const p in params) { if (params[p] !== undefined) { const t = transform && transform[p] || paramsTransform[p]; args[p] = (t) ? t( params[p], args, params, texts ) : params[p]; } } const variant = args['#']; return replaceInString( variant && texts[variant] || texts.std, args ); } function replaceInString( text, params ) { const usedParams = [ '#', '$reviewed' ]; const pattern = /\$\(([A-Z_]+)\)/g; const parts = []; let start = 0; for (let p = pattern.exec( text ); p; p = pattern.exec( text )) { const prop = p[1].toLowerCase(); parts.push( text.substring( start, p.index ), (prop in params ? params[prop] : p[0]) ); usedParams.push(prop); start = pattern.lastIndex; } parts.push( text.substring( start ) ); const remain = (params['#']) ? [] : Object.keys( params ).filter( n => !usedParams.includes(n) ); if (remain.length) { const remains = remain.map( n => `${ n.toUpperCase() } = ${ params[n] }` ).join(', '); return `${ parts.join('') }; ${ remains }`; } return parts.join(''); } /** * Return message string with location if present in compact form (i.e. one line). * * IMPORTANT: * cds-compiler <v4 used following signature: * `messageString( err, normalizeFilename, noMessageId, noHome, moduleName = undefined ) : string` * This signature is still supported for backwards compatibility but is deprecated. * * Example: * <source>.cds:3:11: Error message-id: Can't find type `nu` in this scope (in entity:“E”/element:“e”) * * @param {CompileMessage} err * * @param {object} [config = {}] * * @param {boolean} [config.normalizeFilename] * If true, the file path will be normalized to use `/` as the path separator (instead of `\` on Windows). * * @param {boolean} [config.noMessageId] * If true, will _not_ show the message ID (+ explanation hint) in the output. * * @param {boolean} [config.noHome] * If true, will _not_ show message's semantic location. * * @param {string} [config.moduleForMarker] * If set, downgradable error messages will get a '‹↓›' marker, depending on whether * the message can be downgraded for the given module. A `‹↑›` is used if the message * will be an error in the next major cds-compiler release. * * @returns {string} */ function messageString( err, config ) { // backwards compatibility <v4 if (!config || typeof config === 'boolean' || arguments.length > 2) { config = { /* eslint-disable prefer-rest-params */ normalizeFilename: arguments[1], noMessageId: arguments[2], noHome: arguments[3], moduleForMarker: arguments[4], /* eslint-enable prefer-rest-params */ }; } config.moduleForMarker ??= config.module; // v4.8.0 or earlier compatibility const location = (err.$location?.file ? `${ locationString( err.$location, config.normalizeFilename ) }: ` : ''); const severity = err.severity || 'Error'; const downgradable = severityChangeMarker(err, config); // even with noHome, print err.home if the location is weak const home = !err.home || config.noHome && err.$location?.endLine ? '' : ` (in ${ err.home })`; const msgId = (err.messageId && !config.noMessageId) ? `[${ err.messageId }]` : ''; return `${ location }${ severity }${ downgradable }${ msgId }: ${ err.message }${ home }`; } /** * Return message hash which is either the message string without the file location, * or the full message string if no semantic location is provided. * * @param {CompileMessage} msg * @returns {string} can be used to uniquely identify a message */ function messageHash( msg ) { // parser messages do not provide semantic location, therefore$ we need to use the file location if (!msg.home) return messageString(msg); const copy = { ...msg }; // Note: This is a hack. deduplicateMessages() would otherwise remove // all but one message about duplicated artifacts. if (!msg.messageId || !msg.messageId.includes('duplicate')) copy.$location = undefined; return messageString(copy); } /** * Returns a message string with file- and semantic location if present in multiline form * with a source code snippet below that has highlights for the message's location. * The message (+ message id) are colored according to their severity. * * @param {CompileMessage} err * * @param {object} [config = {}] * * @param {boolean} [config.normalizeFilename] * If true, the file path will be normalized to use `/` as the path separator (instead of `\` on Windows). * * @param {boolean} [config.noMessageId] * If true, will _not_ show the message ID (+ explanation hint) in the output. * * @param {boolean} [config.hintExplanation] * If true, messages with explanations will get a "…" marker. * * @param {string} [config.moduleForMarker] * If set, downgradable error messages will get a '‹↓›' marker, depending on whether * the message can be downgraded for the given module. A `‹↑›` is used if the message * will be an error in the next major cds-compiler release. * * @param {Record<string, string>} [config.sourceMap] * A dictionary of filename<->source-code entries. You can pass the `fileCache` that is used * by the compiler. * * @param {Record<string, number[]>} [config.sourceLineMap] * A dictionary of filename<->source-newline-indices entries. Is used to extract source code * snippets for message locations. If not set, will be set and filled by this function on-demand. * An entry is an array of character/byte offsets to new-lines, for example sourceLineMap[1] is the * end-newline for the second line. * * @param {string} [config.cwd] * The current working directory (cwd) that was passed to the compiler. * This value is only used if a source map is provided and relative paths needs to be * resolved to absolute ones. * * @param {boolean | 'auto' | 'never' | 'always'} [config.color] * If true/'always', ANSI escape codes will be used for coloring the severity. If false/'never', * no coloring will be used. If 'auto', we will decide based on certain factors such * as whether the shell is a TTY and whether the environment variable `NO_COLOR` is * unset or whether `FORCE_COLOR` is set. * * @returns {string} */ function messageStringMultiline( err, config = {} ) { colorTerm.changeColorMode(config ? config.color : 'auto'); config.moduleForMarker ??= config.module; // v4.8.0 or earlier compatibility const explainHelp = (config.hintExplanation && hasMessageExplanation(err.messageId)) ? '…' : ''; const home = !err.home ? '' : (`at ${ err.home }`); const severity = err.severity || 'Error'; const downgradable = severityChangeMarker(err, config); const msgId = (err.messageId && !config.noMessageId) ? `${ downgradable }[${ err.messageId }${ explainHelp }]` : ''; let location = ''; let context = ''; if (err.$location?.file) { location += locationString( err.$location, config.normalizeFilename ); if (home) location += ', '; context = _messageContext(err, config); if (context !== '') context = `\n${ context }`; } else if (!home) { return `${ colorTerm.severity(severity, severity + msgId) } ${ err.message }`; } const additionalIndent = err.$location ? `${ err.$location.endLine || err.$location.line || 1 }`.length : 1; const lineSpacer = `\n ${ ' '.repeat( additionalIndent ) }|`; return `${ colorTerm.severity(severity, severity + msgId) }: ${ err.message }${ lineSpacer }\n ${ location }${ home }${ context }`; } /** * Used by _messageContext() to create an array of line start offsets. * Each entry in the returned array contains the offset for the start line, * where the line is the index in the array. * * @param source * @return {number[]} * @private */ function _createSourceLineMap( source ) { const newlines = [ 0 ]; const re = new RegExp(cdlNewLineRegEx, 'g'); let line; while ((line = re.exec(source)) !== null) newlines.push(line.index + line[0].length); newlines.push(source.length); // EOF marker return newlines; } /** * Returns a context (code) string that is human-readable (similar to rust's compiler). * * IMPORTANT: In case that `config.sourceMap[err.loc.file]` does not exist, this function * uses `path.resolve()` to get the absolute filename. * * Example Output: * | * 3 | num * nu * | ^^ * * @param {CompileMessage} err Error object containing all details like line, message, etc. * @param {object} [config = {}] See `messageStringMultiline()` for details. * * @returns {string} * @private */ function _messageContext( err, config ) { const MAX_COL_LENGTH = 100; const loc = err.$location; if (!loc || !loc.line || !loc.file || !config.sourceMap) return ''; let filepath = config.sourceMap[loc.file]?.realname || loc.file; if (!config.sourceMap[filepath]) filepath = path.resolve(config.cwd || '', filepath); const source = config.sourceMap[filepath]; if (!source || source === true) // true: file exists, no further knowledge return ''; if (!config.sourceLineMap) config.sourceLineMap = Object.create(null); if (!config.sourceLineMap[filepath]) config.sourceLineMap[filepath] = _createSourceLineMap(source); const sourceLines = config.sourceLineMap[filepath]; // Lines are 1-based, we need 0-based ones for arrays const startLine = Math.min(sourceLines.length, loc.line - 1); const endLine = Math.min(sourceLines.length, loc.endLine ? loc.endLine - 1 : startLine); /** Only print N lines even if the error spans more lines. */ const maxLine = Math.min((startLine + 2), endLine); // check that source lines exists if (typeof sourceLines[startLine] !== 'number') return ''; const digits = String(endLine + 1).length; const severity = err.severity || 'Error'; const indent = ' '.repeat(2 + digits); // Columns are limited in width to avoid too long output. // "col" is 1-based but could still be set to 0, e.g. by CSN frontend. const startColumn = Math.min(MAX_COL_LENGTH, loc.col || 1); // end column points to the place *after* the last character index, // e.g. for single character locations it is "start + 1" let endColumn = (loc.endCol && loc.endCol > loc.col) ? loc.endCol - 1 : loc.col; endColumn = Math.min(MAX_COL_LENGTH, endColumn); let msg = `${ indent }|\n`; // print source line(s) for (let line = startLine; line <= maxLine; line++) { // Replaces tabs with 1 space let sourceCode = source.substring(sourceLines[line], sourceLines[line + 1] || source.length).trimEnd(); sourceCode = sourceCode.replace(/\t/g, ' '); if (sourceCode.length >= MAX_COL_LENGTH) sourceCode = sourceCode.slice(0, MAX_COL_LENGTH); // Only prepend space if the line contains any sources. sourceCode = sourceCode.length ? ` ${ sourceCode }` : ''; msg += ` ${ String(line + 1).padStart(digits, ' ') } |${ sourceCode }\n`; } if (startLine === endLine && loc.col > 0) { // highlight only for one-line locations with valid columns // at least one character is highlighted let highlighter = ' '.repeat(startColumn - 1).padEnd(endColumn, '^'); // Indicate that the error is further to the right. if (endColumn === MAX_COL_LENGTH) highlighter = highlighter.replace(' ^', '..^'); msg += `${ indent }| ${ colorTerm.severity(severity, highlighter) }`; } else if (maxLine !== endLine) { // error spans more lines which we don't print msg += `${ indent }| …`; } else { msg += `${ indent }|`; } return msg; } /** * Returns a context (code) string that is human-readable (similar to rust's compiler) * * Example Output: * | * 3 | num * nu * | ^^ * * @param {string[]} sourceLines The source code split up into lines, e.g. by `splitLines(src)` * from `lib/utils/file.js` * @param {CompileMessage} err Error object containing all details like line, message, etc. * @param {object} [config = {}] * @param {boolean | 'auto'} [config.color] If true, ANSI escape codes will be used for coloring the `^`. If false, no * coloring will be used. If 'auto', we will decide based on certain factors such * as whether the shell is a TTY and whether the environment variable 'NO_COLOR' is * unset or `FORCE_COLOR` is set. * @returns {string} * * @deprecated Use `messageStringMultiline()` with `config.sourceMap` and `config.sourceLineMap` instead! */ function messageContext( sourceLines, err, config ) { const loc = err.$location; if (!loc || !loc.line || !loc.file) return ''; colorTerm.changeColorMode(config ? config.color : 'auto'); const sourceMap = { [err.$location.file]: sourceLines.join('\n') }; return _messageContext(err, { ...config, sourceMap }); } /** * Compare two messages `a` and `b`. Return 0 if they are equal, 1 if `a` is * larger than `b`, and -1 if `a` is smaller than `b`. Messages without a location * are considered larger than messages with a location. * * @param {CompileMessage} a * @param {CompileMessage} b */ function compareMessage( a, b ) { const aFile = a.$location && a.$location.file; const bFile = b.$location && b.$location.file; if (aFile && bFile) { const aEnd = a.$location.endLine && a.$location.endCol && a.$location || { endLine: Number.MAX_SAFE_INTEGER, endCol: Number.MAX_SAFE_INTEGER }; // eslint-disable-line @stylistic/max-len const bEnd = b.$location.endLine && b.$location.endCol && b.$location || { endLine: Number.MAX_SAFE_INTEGER, endCol: Number.MAX_SAFE_INTEGER }; // eslint-disable-line @stylistic/max-len return ( c( aFile, bFile ) || c( a.$location.line, b.$location.line ) || c( a.$location.col, b.$location.col ) || c( aEnd.endLine, bEnd.endLine ) || c( aEnd.endCol, bEnd.endCol ) || c( homeSortName( a ), homeSortName( b ) ) || // TODO: severities? c( a.message, b.message ) ); } else if (!aFile && !bFile) { return ( c( homeSortName( a ), homeSortName( b ) ) || c( a.message, b.message ) ); } else if (!aFile) { return (a.messageId && a.messageId.startsWith( 'api-' )) ? -1 : 1; } return (b.messageId && b.messageId.startsWith( 'api-' )) ? 1 : -1; function c( x, y ) { if (x === y) return 0; return (x > y) ? 1 : -1; } } /** * Compare two messages `a` and `b`. Return 0 if they are equal in both their * location and severity, >0 if `a` is larger than `b`, and <0 if `a` is smaller * than `b`. See `compareSeverities()` for how severities are compared. * * @param {CompileMessage} a * @param {CompileMessage} b */ function compareMessageSeverityAware( a, b ) { const c = compareSeverities(a.severity, b.severity); return c || compareMessage( a, b ); } /** * Return sort-relevant part of semantic location (after the ':'). * Messages without semantic locations are considered smaller (for syntax errors) * and (currently - should not happen in v6) larger for other messages. * * @param {CompileMessage} msg */ function homeSortName( { home, messageId } ) { if (!home) return (messageId && /^(syntax|api)-/.test( messageId ) ? ` ${ messageId }` : '~'); return home.substring( home.indexOf(':') ); // i.e. starting with the ':', is always there } /** * Removes duplicate messages from the given messages array without destroying * references to the array, i.e. removes them in-place. * * _Note_: Does NOT keep the original order! * * Two messages are the same if they have the same message hash. See messageHash(). * If one of the two is more precise, then it replaces the other. * A message is more precise if it is contained in the other or if * the first does not have an endLine/endCol. * * @param {CompileMessage[]} messages */ function deduplicateMessages( messages ) { // sort messages to make it processing sequence independent which messages (with // which $location!) wins messages.sort(compareMessage); con