@eclipse-emfcloud/model-validation
Version:
Generic model validation framework.
235 lines (207 loc) • 8.17 kB
text/typescript
// *****************************************************************************
// Copyright (C) 2023-2024 STMicroelectronics.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: MIT License which is
// available at https://opensource.org/licenses/MIT.
//
// SPDX-License-Identifier: EPL-2.0 OR MIT
// *****************************************************************************
import { Severity, severityComparator } from './severity';
/**
* A report of the validation state of some element in the model, or the model
* overall if the `path` is `''`.
*/
export interface Diagnostic {
/**
* The severity of the problem. An `'ok'` severity indicates that no problem
* is manifest.
*/
severity: Severity;
/**
* The source of the problem, indicating the component that reported it.
* Semantics of the source is application-specific but it must not be the empty string.
*/
source: string;
/**
* A code identifying the particular validation rule that reported a problem.
* May be omitted for `ok` {@link severity} diagnostics and for "roll-ups"
* that have {@link children}.
*/
code?: string;
/**
* A JSON Pointer indicating the object within the model with which the diagnostic
* is associated. Thus a diagnostic for the model as a whole has the `''` path.
* An array indices in the path must be specific, not `-`.
*/
path: string;
/**
* A message describing the problem that is suitable for presentation to a human user.
* Likewise if the {@link severity} is `'ok`'.
*/
message: string;
/**
* An optional blob of information providing details about the problem.
* The schema for the data is application-specific and may vary according to
* {@link source} and/or {@link code}.
*/
data?: unknown;
/**
* An optional array of child diagnostics breaking down the validation state
* of the object identified by the {@link path} into finer detail. Typically
* used to represent a "roll-up" validation state for some subtree of the
* model or the model in its entirety.
*
* A diagnostic that has children must not, itself, indicate a discrete problem.
* Instead, any problem specific to the object identified by the {@link path}
* should be included in these children. A diagnostic that has children
* must have a {@link severity} that is the maximum of the severities of those
* children.
*/
children?: Diagnostic[];
}
/**
* A type of `Diagnostic` that is guaranteed to have a `children` array.
* Used internally only by the merge algorithm. Not exported to clients.
*/
type MultiDiagnostic = Diagnostic & { children: Diagnostic[] };
/** A default diagnostic source to assign when none other is available. */
const DEFAULT_SOURCE = '@eclipse-emfcloud/model-validation';
/**
* Obtain a diagnostic of `'ok'` severity indicating that validation found no problem.
*
* @param [source] an optional source for the resulting diagnostic.
* The default indicates this validation framework package
* @return an `'ok'` diagnostic with empty (`''`) path as the path does not really matter in the absence of a problem
*/
export const ok = (source = DEFAULT_SOURCE): Diagnostic => ({
severity: 'ok',
message: 'OK.',
source,
path: '',
});
/**
* Merge zero or more diagnostics into one. All `'ok'` diagnostics
* are elided. As a special case, an empty list of `diagnostics`
* or an array containing only `'ok'` diagnostics merges to an
* {@link ok} diagnostic. As another special case, a single
* input merges to itself.
*
* For any input to the merge that has {@link Diagnostic.children children},
* those children (except any that are OK) are added to the merge result
* in place of the original input, as that parent diagnostic is expected
* to be the result of a previous merge by this same algorithm.
* In consequence, consistent use of this merge function will always
* result in a flat structure of a diagnostic with zero or more children
* that all have themselves no children.
*
* In a merge result that combines multiple non-OK diagnostics from the
* inputs, all of the following conditions hold:
*
* - the {@link Diagnostic.severity severity} of the merge result is the
* {@link severityComparator.max maximum} of the severities of its children
* - the {@link Diagnostic.source source} of the merge result is the
* same as the source of its children if they all have the same source.
* Otherwise, it is the empty string (`''`)
* - the {@link Diagnostic.code code} of the merge result is `undefined`
* - the {@link Diagnostic.path path} of the merge result is the longest
* common prefix of all of the paths of its children, which may be
* their path if they all have the same path, or may be the empty string
* (`''`) denoting the model as a whole if they have no common prefix
* - the {@link Diagnostic.message message} of the merge result is a
* non-localized string indicating how many problems it aggregates
*
* @param diagnostics zero or more diagnostics to merge
* @return a compact merge of the `diagnostics`
*/
export const merge = (...diagnostics: Diagnostic[]): Diagnostic => {
const nonOK = diagnostics.filter(notOK);
if (!nonOK || !nonOK.length) {
return ok();
}
if (nonOK.length === 1 && !hasChildren(nonOK[0])) {
return nonOK[0];
}
const [first] = nonOK.splice(0, 1);
const result: MultiDiagnostic = {
severity: first.severity,
source: first.source,
path: first.path,
message: '', // Will replace this later
children: hasChildren(first) ? [...first.children.filter(notOK)] : [first],
};
nonOK.forEach((d) => {
if (d.children !== undefined && d.children.length > 0) {
result.children.push(...d.children.filter(notOK));
} else {
result.children.push(d);
}
});
// After all the filtering we may now have only one child or even none.
switch (result.children.length) {
case 0:
// Canonically, no problem.
return ok();
case 1:
// It stands for itself.
return result.children[0];
default:
// Continue, below
break;
}
result.severity = result.children
.map((d) => d.severity)
.reduce(severityComparator.max, 'ok');
result.path = result.children
.slice(1)
.map((d) => d.path)
.reduce(longestCommonPrefix, result.children[0].path);
result.source = unique(result.children.map((d) => d.source)) || '';
result.message = `${result.children.length} problems found.`;
return result;
};
/** A function determining whether a diagnostic is not OK. */
const notOK = (d: Diagnostic) => severityComparator(d.severity, 'ok') > 0;
/** A guard determining whether a diagnostic has children. */
const hasChildren = (d: Diagnostic): d is MultiDiagnostic =>
d.children !== undefined && d.children.length > 0;
/** Compute the longest common prefix of two JSON pointers. */
const longestCommonPrefix = (path1: string, path2: string): string => {
const segs1 = (path1 || '').split('/');
const segs2 = (path2 || '').split('/');
const count = Math.min(segs1.length, segs2.length);
let prefLength = 0;
for (; prefLength < count; prefLength++) {
if (segs1[prefLength] !== segs2[prefLength]) {
break;
}
}
if (prefLength === 0) {
return '';
}
return segs1.slice(0, prefLength).join('/');
};
/**
* Extract the unique value of the given `items` or else
* `undefined` if they are not unique.
*
* @param items some items
* @returns the unique value, or `undefined` if `items` is empty
*/
const unique = <T>(items: T[]): T | undefined => {
let result: T | undefined;
for (const next of items) {
if (result === undefined) {
result = next;
} else if (result !== next) {
result = undefined;
break;
}
}
return result;
};