Source: validate.js

import { validateHedDatasetWithContext } from '../validator/dataset'
import { validateHedString } from '../validator/event'
import { BidsDataset, BidsEventFile, BidsHedIssue, BidsIssue } from './types'
import { buildBidsSchemas } from './schema'
import { generateIssue, Issue, IssueError } from '../common/issues/issues'

/**
 * Validate a BIDS dataset.
 *
 * @param {BidsDataset} dataset The BIDS dataset.
 * @param {object} schemaDefinition The version spec for the schema to be loaded.
 * @return {Promise<BidsIssue[]>} Any issues found.
 */
export function validateBidsDataset(dataset, schemaDefinition) {
  return buildBidsSchemas(dataset, schemaDefinition).then(
    ([hedSchemas, schemaLoadIssues]) => {
      return validateFullDataset(dataset, hedSchemas)
        .catch(BidsIssue.generateInternalErrorPromise)
        .then((issues) =>
          issues.concat(convertHedIssuesToBidsIssues(schemaLoadIssues, dataset.datasetDescription.file)),
        )
    },
    (issues) => convertHedIssuesToBidsIssues(issues, dataset.datasetDescription.file),
  )
}

/**
 * Validate a full BIDS dataset using a HED schema collection.
 *
 * @param {BidsDataset} dataset A BIDS dataset.
 * @param {Schemas} hedSchemas A HED schema collection.
 * @return {Promise<BidsIssue[]>|Promise<never>} Any issues found.
 */
function validateFullDataset(dataset, hedSchemas) {
  try {
    const [sidecarErrorsFound, sidecarIssues] = validateSidecars(dataset.sidecarData, hedSchemas)
    const [hedColumnErrorsFound, hedColumnIssues] = validateHedColumn(dataset.eventData, hedSchemas)
    if (sidecarErrorsFound || hedColumnErrorsFound) {
      return Promise.resolve([...sidecarIssues, ...hedColumnIssues])
    }
    const eventFileIssues = dataset.eventData.map((eventFileData) => {
      return validateBidsTsvFile(eventFileData, hedSchemas)
    })
    return Promise.resolve([].concat(sidecarIssues, hedColumnIssues, ...eventFileIssues))
  } catch (e) {
    return Promise.reject(e)
  }
}

/**
 * Validate a BIDS TSV file.
 *
 * @param {BidsTsvFile} tsvFileData A BIDS TSV file.
 * @param {Schemas} hedSchemas A HED schema collection.
 * @return {BidsIssue[]} Any issues found.
 */
function validateBidsTsvFile(tsvFileData, hedSchemas) {
  const [hedStrings, tsvIssues] = parseTsvHed(tsvFileData)
  if (!hedStrings) {
    return []
  } else {
    const datasetIssues = validateCombinedDataset(hedStrings, hedSchemas, tsvFileData)
    return [...tsvIssues, ...datasetIssues]
  }
}

/**
 * @typedef {Object} ValidationResult
 * @property {boolean} isError Whether errors (as opposed to warnings) were found.
 * @property {BidsHedIssue[]} issues All issues found.
 */

/**
 * Validate a collection of BIDS sidecars.
 *
 * @param {BidsSidecar[]} sidecarData A collection of BIDS sidecars.
 * @param {Schemas} hedSchemas A HED schema collection.
 * @return {ValidationResult} Whether errors (as opposed to warnings) were founds, and all issues found.
 */
function validateSidecars(sidecarData, hedSchemas) {
  const issues = []
  // validate the HED strings in the json sidecars
  for (const sidecar of sidecarData) {
    const valueStringIssues = validateStrings(sidecar.hedValueStrings, hedSchemas, sidecar.file, {
      expectValuePlaceholderString: true,
      definitionsAllowed: 'no',
    })
    const categoricalStringIssues = validateStrings(sidecar.hedCategoricalStrings, hedSchemas, sidecar.file, {
      expectValuePlaceholderString: false,
      definitionsAllowed: 'exclusive',
    })
    issues.push(...valueStringIssues, ...categoricalStringIssues)
  }
  const sidecarErrorsFound = issues.some((issue) => issue.isError())
  return [sidecarErrorsFound, issues]
}

/**
 * Validate the HED columns of a collection of BIDS event TSV files.
 *
 * @param {BidsEventFile[]} eventData A collection of BIDS event TSV files.
 * @param {Schemas} hedSchemas A HED schema collection.
 * @return {ValidationResult} Whether errors (as opposed to warnings) were founds, and all issues found.
 */
function validateHedColumn(eventData, hedSchemas) {
  const issues = eventData.flatMap((eventFileData) => {
    return validateStrings(eventFileData.hedColumnHedStrings, hedSchemas, eventFileData.file, {
      expectValuePlaceholderString: false,
      definitionsAllowed: 'no',
    })
  })
  const errorsFound = issues.some((issue) => issue.isError())
  return [errorsFound, issues]
}

/**
 * @typedef {Object} CombinedResult
 * @property {string[]} hedStrings The combined HED strings for this BIDS TSV file.
 * @property {BidsIssue[]} issues All issues found during the combination.
 */

/**
 * Combine the BIDS sidecar HED data into a BIDS TSV file's HED data.
 *
 * @param {BidsTsvFile} tsvFileData A BIDS TSV file.
 * @return {CombinedResult} The combined HED strings for this BIDS TSV file, and all issues found during the combination.
 */
function parseTsvHed(tsvFileData) {
  const hedStrings = []
  const issues = []
  const sidecarHedColumnIndices = {}
  for (const sidecarHedColumn of tsvFileData.sidecarHedData.keys()) {
    const sidecarHedColumnHeader = tsvFileData.parsedTsv.headers.indexOf(sidecarHedColumn)
    if (sidecarHedColumnHeader > -1) {
      sidecarHedColumnIndices[sidecarHedColumn] = sidecarHedColumnHeader
    }
  }
  if (tsvFileData.hedColumnHedStrings.length + sidecarHedColumnIndices.length === 0) {
    return [[], []]
  }

  tsvFileData.parsedTsv.rows.slice(1).forEach((rowCells, rowIndex) => {
    // get the 'HED' field
    const hedStringParts = []
    if (tsvFileData.hedColumnHedStrings[rowIndex]) {
      hedStringParts.push(tsvFileData.hedColumnHedStrings[rowIndex])
    }
    for (const [sidecarHedColumn, sidecarHedIndex] of Object.entries(sidecarHedColumnIndices)) {
      const sidecarHedData = tsvFileData.sidecarHedData.get(sidecarHedColumn)
      const rowCell = rowCells[sidecarHedIndex]
      if (rowCell && rowCell !== 'n/a') {
        let sidecarHedString
        if (!sidecarHedData) {
          continue
        }
        if (typeof sidecarHedData === 'string') {
          sidecarHedString = sidecarHedData.replace('#', rowCell)
        } else {
          sidecarHedString = sidecarHedData[rowCell]
        }
        if (sidecarHedString !== undefined) {
          hedStringParts.push(sidecarHedString)
        } else {
          issues.push(
            new BidsHedIssue(
              generateIssue('sidecarKeyMissing', {
                key: rowCell,
                column: sidecarHedColumn,
                file: tsvFileData.file.relativePath,
              }),
              tsvFileData.file,
            ),
          )
        }
      }
    }

    if (hedStringParts.length > 0) {
      hedStrings.push(hedStringParts.join(','))
    }
  })

  return [hedStrings, issues]
}

/**
 * Validate the HED data in a combined event TSV file/sidecar BIDS data collection.
 *
 * @param {string[]} hedStrings The HED strings in the data collection.
 * @param {Schemas} hedSchemas The HED schema collection to validate against.
 * @param {BidsTsvFile} tsvFileData The BIDS event TSV file being validated.
 * @return {BidsHedIssue[]} Any issues found.
 */
function validateCombinedDataset(hedStrings, hedSchemas, tsvFileData) {
  const [, hedIssues] = validateHedDatasetWithContext(hedStrings, tsvFileData.mergedSidecar.hedStrings, hedSchemas, {
    checkForWarnings: true,
    validateDatasetLevel: tsvFileData instanceof BidsEventFile,
  })
  return convertHedIssuesToBidsIssues(hedIssues, tsvFileData.file)
}

/**
 * Validate a set of HED strings.
 *
 * @param {string[]} hedStrings The HED strings to validate.
 * @param {Schemas} hedSchemas The HED schema collection to validate against.
 * @param {Object} fileObject A BIDS-format file object used to generate {@link BidsHedIssue} objects.
 * @param {Object} settings Options to pass to {@link validateHedString}.
 * @return {BidsHedIssue[]} Any issues found.
 */
function validateStrings(hedStrings, hedSchemas, fileObject, settings) {
  const issues = []
  for (const hedString of hedStrings) {
    if (!hedString) {
      continue
    }
    const options = {
      checkForWarnings: true,
      ...settings,
    }
    const [, hedIssues] = validateHedString(hedString, hedSchemas, options)
    const convertedIssues = convertHedIssuesToBidsIssues(hedIssues, fileObject)
    issues.push(...convertedIssues)
  }
  return issues
}

/**
 * Convert one or more HED issues into BIDS-compatible issues.
 *
 * @param {IssueError|Issue[]} hedIssues One or more HED-format issues.
 * @param {Object} file A BIDS-format file object used to generate {@link BidsHedIssue} objects.
 * @return {BidsHedIssue[]} The passed issue(s) in BIDS-compatible format.
 */
function convertHedIssuesToBidsIssues(hedIssues, file) {
  if (hedIssues instanceof IssueError) {
    return [new BidsHedIssue(hedIssues.issue, file)]
  } else {
    return hedIssues.map((hedIssue) => new BidsHedIssue(hedIssue, file))
  }
}