diff --git a/extension/package.json b/extension/package.json index 223346b9..0d8dd8c1 100644 --- a/extension/package.json +++ b/extension/package.json @@ -172,6 +172,11 @@ "title": "Create a Markdown file", "category": "STPA PDF Creation" }, + { + "command": "pasta.stpa.ft.generation", + "title": "Generate Fault Trees", + "category": "STPA and FTA" + }, { "command": "pasta.fta.cutSets", "title": "Generate the cut sets", @@ -237,6 +242,10 @@ "command": "pasta.stpa.md.creation", "when": "editorLangId == 'stpa'" }, + { + "command": "pasta.stpa.ft.generation", + "when": "editorLangId == 'stpa'" + }, { "command": "pasta.fta.cutSets", "when": "editorLangId == 'fta'" @@ -262,7 +271,7 @@ "group": "checks" }, { - "submenu": "pasta.generate", + "submenu": "pasta.fta.generate", "group": "generate" }, { @@ -274,6 +283,11 @@ "command": "pasta.stpa.md.creation", "when": "editorLangId == 'stpa'", "group": "stpa" + }, + { + "command": "pasta.stpa.ft.generation", + "when": "editorLangId == 'stpa'", + "group": "stpa" } ], "pasta.stpa.checks": [ @@ -298,7 +312,7 @@ "group": "navigation" } ], - "pasta.generate": [ + "pasta.fta.generate": [ { "command": "pasta.fta.cutSets", "when": "editorLangId == 'fta'", @@ -332,16 +346,6 @@ "command": "pasta.contextTable.open", "when": "resourceExtname == '.stpa'", "group": "navigation" - }, - { - "command": "pasta.fta.cutSets", - "when": "resourceExtname == '.fta'", - "group": "navigation" - }, - { - "command": "pasta.fta.minimalCutSets", - "when": "resourceExtname == '.fta'", - "group": "navigation" } ] }, @@ -351,7 +355,7 @@ "label": "Validation Checks" }, { - "id": "pasta.generate", + "id": "pasta.fta.generate", "label": "Generate cut sets" } ], diff --git a/extension/src-language-server/fta/analysis/fta-cutSet-calculator.ts b/extension/src-language-server/fta/analysis/fta-cutSet-calculator.ts index c103cb5c..abe13f54 100644 --- a/extension/src-language-server/fta/analysis/fta-cutSet-calculator.ts +++ b/extension/src-language-server/fta/analysis/fta-cutSet-calculator.ts @@ -29,14 +29,17 @@ import { } from "../../generated/ast"; import { namedFtaElement } from "../utils"; +/* element for which the cut sets were determined */ +export let topOfAnalysis: string | undefined; + /** * Determines the minimal cut sets for the fault tree constructured by {@code allNodes}. * @param allNodes All nodes in the fault tree. * @returns the minimal cut sets for a fault tree. */ -export function determineMinimalCutSets(allNodes: AstNode[]): Set[] { +export function determineMinimalCutSets(allNodes: AstNode[], startNode?: namedFtaElement): Set[] { // TODO: add minimal flag (could reduce computation cost) - const allCutSets = determineCutSetsForFT(allNodes); + const allCutSets = determineCutSetsForFT(allNodes, startNode); // Cut sets are minimal if removing one element destroys the cut set // If cut set contains another cut set from the array, remove it since it is not minimal @@ -69,9 +72,10 @@ function checkMinimalCutSet(cutSet: Set, allCutSets: Set[] { +export function determineCutSetsForFT(allNodes: AstNode[], startNode?: namedFtaElement): Set[] { /* Idea: Start from the top event. Get the only child of top event (will always be only one) as our starting node. @@ -79,8 +83,13 @@ export function determineCutSetsForFT(allNodes: AstNode[]): Set In the evaluation we check if the child has children too and do the same recursively until the children are components. Depending on the type of the node process the results of the children differently. */ - - const startNode = getChildOfTopEvent(allNodes); + + topOfAnalysis = startNode?.name; + if (!startNode) { + topOfAnalysis = (allNodes.find((node) => isTopEvent(node)) as namedFtaElement).name; + // if no start node is given, the top event is used as start node + startNode = getChildOfTopEvent(allNodes); + } if (startNode) { // determine the cut sets of the Fault Tree return determineCutSetsForGate(startNode, allNodes); @@ -213,9 +222,9 @@ function getChildrenOfNode(node: AstNode): namedFtaElement[] { * @returns the child of the top event. */ function getChildOfTopEvent(allNodes: AstNode[]): namedFtaElement | undefined { - const topEventChildren = (allNodes.find((node) => isTopEvent(node)) as TopEvent).children; - if (topEventChildren.length !== 0) { - return topEventChildren[0].ref; + const topEventChild = (allNodes.find((node) => isTopEvent(node)) as TopEvent).child; + if (topEventChild) { + return topEventChild.ref; } } diff --git a/extension/src-language-server/fta/diagram/fta-diagram-generator.ts b/extension/src-language-server/fta/diagram/fta-diagram-generator.ts index fc894f84..09d2ac00 100644 --- a/extension/src-language-server/fta/diagram/fta-diagram-generator.ts +++ b/extension/src-language-server/fta/diagram/fta-diagram-generator.ts @@ -17,17 +17,38 @@ import { AstNode } from "langium"; import { GeneratorContext, IdCache, LangiumDiagramGenerator } from "langium-sprotty"; -import { SLabel, SModelElement, SModelRoot } from "sprotty-protocol"; -import { ModelFTA, isComponent, isCondition, isKNGate } from "../../generated/ast"; +import { SModelElement, SModelRoot, SNode, SLabel } from "sprotty-protocol"; +import { Component, Condition, Gate, ModelFTA, isComponent, isCondition, isKNGate } from "../../generated/ast"; +import { getDescription } from "../../utils"; +import { topOfAnalysis } from "../analysis/fta-cutSet-calculator"; import { FtaServices } from "../fta-module"; -import { FtaSynthesisOptions, noCutSet, spofsSet } from "../fta-synthesis-options"; import { namedFtaElement } from "../utils"; -import { FTAEdge, FTANode } from "./fta-interfaces"; -import { FTA_EDGE_TYPE, FTA_GRAPH_TYPE, FTA_NODE_TYPE } from "./fta-model"; +import { DescriptionNode, FTAEdge, FTAGraph, FTANode, FTAPort } from "./fta-interfaces"; +import { + FTA_DESCRIPTION_NODE_TYPE, + FTA_EDGE_TYPE, + FTA_GRAPH_TYPE, + FTA_INVISIBLE_EDGE_TYPE, + FTA_NODE_TYPE, + FTA_PORT_TYPE, + FTNodeType, + PortSide, +} from "./fta-model"; +import { FtaSynthesisOptions, noCutSet, spofsSet } from "./fta-synthesis-options"; import { getFTNodeType, getTargets } from "./utils"; export class FtaDiagramGenerator extends LangiumDiagramGenerator { protected readonly options: FtaSynthesisOptions; + + /** Saves the Ids of the generated SNodes */ + protected idToSNode: Map = new Map(); + /** Saves the parent node of a gate */ + protected parentOfGate: Map = new Map(); + /** Saves the description node of a gate */ + protected descriptionOfGate: Map = new Map(); + /** Saves the port of a node */ + protected nodeToPort: Map = new Map(); + constructor(services: FtaServices) { super(services); this.options = services.options.SynthesisOptions; @@ -42,23 +63,35 @@ export class FtaDiagramGenerator extends LangiumDiagramGenerator { const { document } = args; const model = document.parseResult.value; const idCache = args.idCache; + // reset maps + this.descriptionOfGate = new Map(); + this.parentOfGate = new Map(); + this.idToSNode = new Map(); const ftaChildren: SModelElement[] = [ // create nodes for top event, components, conditions, and gates - this.generateFTNode(model.topEvent, idCache), ...model.components.map((component) => this.generateFTNode(component, idCache)), ...model.conditions.map((condition) => this.generateFTNode(condition, idCache)), - ...model.gates.map((gate) => this.generateFTNode(gate, idCache)), + ...model.gates.map((gate) => this.generateGate(gate, idCache)), // create edges for the gates and the top event ...model.gates.map((gate) => this.generateEdges(gate, idCache)).flat(1), - ...this.generateEdges(model.topEvent, idCache), ]; - return { + if (model.topEvent) { + ftaChildren.push( + this.generateFTNode(model.topEvent, idCache), + ...this.generateEdges(model.topEvent, idCache) + ); + } + + const graph: FTAGraph = { type: FTA_GRAPH_TYPE, id: "root", children: ftaChildren, }; + graph.modelOrder = this.options.getModelOrder(); + + return graph; } /** @@ -70,14 +103,60 @@ export class FtaDiagramGenerator extends LangiumDiagramGenerator { protected generateEdges(node: AstNode, idCache: IdCache): SModelElement[] { const elements: SModelElement[] = []; const sourceId = idCache.getId(node); - // for every reference an edge is created - const targets = getTargets(node); - for (const target of targets) { - const targetId = idCache.getId(target); - const edgeId = idCache.uniqueId(`${sourceId}_${targetId}`, undefined); - if (sourceId && targetId) { - const e = this.generateFTEdge(edgeId, sourceId, targetId, idCache); - elements.push(e); + if (sourceId) { + // for every reference an edge is created + const targets = getTargets(node); + for (const target of targets) { + const targetId = idCache.getId(target); + const edgeId = idCache.uniqueId(`${sourceId}_${targetId}`, undefined); + + // create port for the source node + const sourceNode = this.idToSNode.get(sourceId); + const sourcePortId = idCache.uniqueId(edgeId + "_port"); + sourceNode?.children?.push(this.createFTAPort(sourcePortId, PortSide.SOUTH)); + + // create port for source parent and edge to this port + let sourceParentPortId: string | undefined; + if (this.parentOfGate.has(sourceId)) { + const parent = this.parentOfGate.get(sourceId); + sourceParentPortId = idCache.uniqueId(edgeId + "_port"); + parent?.children?.push(this.createFTAPort(sourceParentPortId, PortSide.SOUTH)); + const betweenEdgeId = idCache.uniqueId(edgeId + "_betweenEdge"); + const e = this.generateFTEdge( + betweenEdgeId, + sourcePortId, + sourceParentPortId, + FTA_EDGE_TYPE, + idCache + ); + parent?.children?.push(e); + } + + // create edge to target + if (targetId) { + let targetPortId: string | undefined = undefined; + if (this.parentOfGate.has(targetId)) { + // get the port id from the parent + const parent = this.parentOfGate.get(targetId); + targetPortId = this.nodeToPort.get(parent?.id ?? "")?.id; + } else { + // get the port id from the target node + const targetNode = this.idToSNode.get(targetId); + targetPortId = this.nodeToPort.get(targetNode?.id ?? "")?.id; + } + + if (targetPortId) { + // create edge from source to target + const e = this.generateFTEdge( + edgeId, + sourceParentPortId ?? sourcePortId, + targetPortId, + FTA_EDGE_TYPE, + idCache + ); + elements.push(e); + } + } } } return elements; @@ -96,12 +175,13 @@ export class FtaDiagramGenerator extends LangiumDiagramGenerator { edgeId: string, sourceId: string, targetId: string, + type: string, idCache: IdCache, label?: string ): FTAEdge { const children = label ? this.createEdgeLabel(label, edgeId, idCache) : []; return { - type: FTA_EDGE_TYPE, + type: type, id: edgeId, sourceId: sourceId, targetId: targetId, @@ -110,6 +190,95 @@ export class FtaDiagramGenerator extends LangiumDiagramGenerator { }; } + protected generateGate(node: Gate, idCache: IdCache): FTANode { + const gateNode = this.generateFTNode(node, idCache); + this.idToSNode.set(gateNode.id, gateNode); + if (!this.options.getShowGateDescriptions() || node.description === undefined) { + return gateNode; + } + // create node for gate description + const descriptionNodeId = idCache.uniqueId(node.name + "Description"); + const label = getDescription( + node.description, + this.options.getLabelManagement(), + this.options.getLabelShorteningWidth(), + descriptionNodeId, + idCache + ).reverse(); + const descriptionNode: DescriptionNode = { + type: FTA_DESCRIPTION_NODE_TYPE, + id: descriptionNodeId, + name: node.description ?? "", + children: label, + layout: "stack", + inCurrentSelectedCutSet: gateNode.inCurrentSelectedCutSet, + notConnectedToSelectedCutSet: gateNode.notConnectedToSelectedCutSet, + layoutOptions: { + paddingTop: 10.0, + paddingBottom: 10.0, + paddngLeft: 10.0, + paddingRight: 10.0, + }, + }; + + // create invisible edge from description to gate + const invisibleEdge = this.generateFTEdge( + idCache.uniqueId(node.name + "InvisibleEdge"), + descriptionNode.id, + this.nodeToPort.get(gateNode.id)?.id ?? gateNode.id, + FTA_INVISIBLE_EDGE_TYPE, + idCache + ); + + const parentId = idCache.uniqueId(node.name + "Parent"); + const port = this.createFTAPort(idCache.uniqueId(parentId + "_port"), PortSide.NORTH); + + // create invisible edge from parent to description + const betweenEdgeId = idCache.uniqueId(node.name + "InvisibleEdge"); + const invisibleEdgeParetToDescription = this.generateFTEdge( + betweenEdgeId, + port.id, + descriptionNode.id, + FTA_INVISIBLE_EDGE_TYPE, + idCache + ); + + // order is important to have the descriptionNode above the gateNode + const children: SModelElement[] = [ + descriptionNode, + gateNode, + invisibleEdge, + port, + invisibleEdgeParetToDescription, + ]; + + // create invisible node that contains the desciprion and gate node + const parent = { + type: FTA_NODE_TYPE, + id: parentId, + name: node.name, + nodeType: FTNodeType.PARENT, + description: "", + children: children, + layout: "stack", + inCurrentSelectedCutSet: gateNode.inCurrentSelectedCutSet, + notConnectedToSelectedCutSet: gateNode.notConnectedToSelectedCutSet, + layoutOptions: { + paddingTop: 0.0, + paddingBottom: 10.0, + paddngLeft: 0.0, + paddingRight: 0.0, + }, + }; + + // update maps + this.idToSNode.set(descriptionNode.id, descriptionNode); + this.descriptionOfGate.set(gateNode.id, descriptionNode); + this.parentOfGate.set(gateNode.id, parent); + this.nodeToPort.set(parent.id, port); + return parent; + } + /** * Generates a single FTANode for the given {@code node}. * @param node The FTA component the node should be created for. @@ -117,42 +286,81 @@ export class FtaDiagramGenerator extends LangiumDiagramGenerator { * @returns a FTANode representing {@code node}. */ protected generateFTNode(node: namedFtaElement, idCache: IdCache): FTANode { - const nodeId = idCache.uniqueId(node.name, node); + const nodeId = idCache.uniqueId(node.name.replace(" ", ""), node); const children: SModelElement[] = this.createNodeLabel(node.name, nodeId, idCache); + if (this.options.getShowComponentDescriptions() && (node.$type === Component || node.$type === Condition) && node.description !== undefined) { + const label = getDescription( + node.description, + this.options.getLabelManagement(), + this.options.getLabelShorteningWidth(), + nodeId, + idCache + ); + children.push(...label.reverse()); + } + // one port for outgoing edges + const port = this.createFTAPort(idCache.uniqueId(nodeId + "_port"), PortSide.NORTH); + children.push(port); + this.nodeToPort.set(nodeId, port); const description = isComponent(node) || isCondition(node) ? node.description : ""; const set = this.options.getCutSet(); - let includedInCutSet = set !== noCutSet.id ? set.includes(node.name) : false; + let includedInCutSet = set !== noCutSet.id ? set.includes(node.name + ",") || set.includes(node.name + "]") : false; let notConnected = set !== noCutSet.id ? !includedInCutSet : false; // single points of failure should be shown if (set === spofsSet.id) { const spofs = this.options.getSpofs(); includedInCutSet = spofs.includes(node.name); - notConnected = false; - } + notConnected = !includedInCutSet; + } + + const ftNode = this.createNode( + nodeId, + node.name, + getFTNodeType(node), + description, + children, + includedInCutSet, + notConnected, + topOfAnalysis === node.name && this.options.getCutSet() !== noCutSet.id + ); + + this.idToSNode.set(nodeId, ftNode); + + if (isKNGate(node)) { + ftNode.k = node.k; + ftNode.n = node.children.length; + } + return ftNode; + } - const ftNode = { + protected createNode( + id: string, + name: string, + type: FTNodeType, + description: string, + children: SModelElement[], + includedInCutSet: boolean | undefined, + notConnected: boolean | undefined, + topOfAnalysis: boolean | undefined + ): FTANode { + return { type: FTA_NODE_TYPE, - id: nodeId, - name: node.name, - nodeType: getFTNodeType(node), + id: id, + name: name, + nodeType: type, description: description, children: children, layout: "stack", inCurrentSelectedCutSet: includedInCutSet, notConnectedToSelectedCutSet: notConnected, + topOfAnalysis: topOfAnalysis, layoutOptions: { paddingTop: 10.0, paddingBottom: 10.0, paddngLeft: 10.0, paddingRight: 10.0, }, - } as FTANode; - - if (isKNGate(node)) { - ftNode.k = node.k; - ftNode.n = node.children.length; - } - return ftNode; + }; } /** @@ -165,8 +373,8 @@ export class FtaDiagramGenerator extends LangiumDiagramGenerator { protected createNodeLabel(label: string, id: string, idCache: IdCache): SLabel[] { return [ { - type: "label", - id: idCache.uniqueId(id + ".label"), + type: 'label', + id: idCache.uniqueId(id + "_label"), text: label, }, ]; @@ -183,9 +391,23 @@ export class FtaDiagramGenerator extends LangiumDiagramGenerator { return [ { type: "label:xref", - id: idCache.uniqueId(id + ".label"), + id: idCache.uniqueId(id + "_label"), text: label, }, ]; } + + /** + * Creates an FTAPort. + * @param id The ID of the port. + * @param side The side of the port. + * @returns an FTAPort. + */ + protected createFTAPort(id: string, side: PortSide): FTAPort { + return { + type: FTA_PORT_TYPE, + id: id, + side: side, + }; + } } diff --git a/extension/src-language-server/fta/diagram/fta-interfaces.ts b/extension/src-language-server/fta/diagram/fta-interfaces.ts index 42f2390c..eb2c9b99 100644 --- a/extension/src-language-server/fta/diagram/fta-interfaces.ts +++ b/extension/src-language-server/fta/diagram/fta-interfaces.ts @@ -15,8 +15,8 @@ * SPDX-License-Identifier: EPL-2.0 */ -import { SEdge, SNode } from "sprotty-protocol"; -import { FTNodeType } from "./fta-model"; +import { Point, SEdge, SGraph, SNode, SPort } from "sprotty-protocol"; +import { FTNodeType, PortSide } from "./fta-model"; /** * Node of a fault tree. @@ -25,15 +25,38 @@ export interface FTANode extends SNode { name: string; nodeType: FTNodeType; description: string; + topOfAnalysis?: boolean; inCurrentSelectedCutSet?: boolean; notConnectedToSelectedCutSet?: boolean; k?: number; n?: number; } +/** + * FTA Graph. + */ +export interface FTAGraph extends SGraph { + modelOrder?: boolean; +} + +/** + * Description node of a fault tree. + */ +export interface DescriptionNode extends SNode { + name: string; + inCurrentSelectedCutSet?: boolean; + notConnectedToSelectedCutSet?: boolean; +} + /** * Edge of a fault tree. */ export interface FTAEdge extends SEdge { notConnectedToSelectedCutSet?: boolean; + junctionPoints?: Point[]; +} + +/** Port representing a port in the FTA graph. */ +export interface FTAPort extends SPort { + side?: PortSide; } diff --git a/extension/src-language-server/fta/diagram/fta-layout-config.ts b/extension/src-language-server/fta/diagram/fta-layout-config.ts index 004cb9a5..5e45594b 100644 --- a/extension/src-language-server/fta/diagram/fta-layout-config.ts +++ b/extension/src-language-server/fta/diagram/fta-layout-config.ts @@ -17,19 +17,68 @@ import { LayoutOptions } from "elkjs"; import { DefaultLayoutConfigurator } from "sprotty-elk/lib/elk-layout"; -import { SGraph, SModelIndex, SNode } from "sprotty-protocol"; +import { SModelIndex, SNode } from "sprotty-protocol"; +import { FTAGraph, FTANode, FTAPort } from "./fta-interfaces"; +import { FTA_DESCRIPTION_NODE_TYPE, FTA_NODE_TYPE, FTA_PORT_TYPE, FTNodeType, PortSide } from "./fta-model"; export class FtaLayoutConfigurator extends DefaultLayoutConfigurator { - protected graphOptions(_sgraph: SGraph, _index: SModelIndex): LayoutOptions { - return { + protected graphOptions(sgraph: FTAGraph, _index: SModelIndex): LayoutOptions { + const options: LayoutOptions = { "org.eclipse.elk.direction": "DOWN", - "org.eclipse.elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX", + "org.eclipse.elk.layered.nodePlacement.strategy": "BRANDES_KOEPF", + "org.eclipse.elk.portConstraints": "FIXED_SIDE", + "org.eclipse.elk.hierarchyHandling": "INCLUDE_CHILDREN", + "org.eclipse.elk.spacing.portPort": "0.0", + "org.eclipse.elk.layered.nodePlacement.bk.fixedAlignment": "BALANCED" }; + + if (sgraph.modelOrder) { + options["org.eclipse.elk.layered.considerModelOrder.strategy"] = "NODES_AND_EDGES"; + options["org.eclipse.elk.layered.crossingMinimization.forceNodeModelOrder"] = "true"; + options["org.eclipse.elk.separateConnectedComponents"] = "false"; + } + return options; } - protected nodeOptions(_snode: SNode, _index: SModelIndex): LayoutOptions | undefined { - return { + protected nodeOptions(snode: SNode, _index: SModelIndex): LayoutOptions | undefined { + const options: LayoutOptions = { + "org.eclipse.elk.portConstraints": "FIXED_SIDE", + "org.eclipse.elk.spacing.portPort": "0.0", "org.eclipse.elk.nodeLabels.placement": "INSIDE V_CENTER H_CENTER", }; + switch (snode.type) { + case FTA_NODE_TYPE: + switch ((snode as FTANode).nodeType) { + case FTNodeType.PARENT: + options["org.eclipse.elk.direction"] = "DOWN"; + options["org.eclipse.elk.padding"] = "[top=0.0,left=0.0,bottom=10.0,right=0.0]"; + options["org.eclipse.elk.layered.spacing.nodeNodeBetweenLayers"] = "2"; + options["org.eclipse.elk.hierarchyHandling"] = "INCLUDE_CHILDREN"; + break; + case FTNodeType.COMPONENT: + case FTNodeType.CONDITION: + options["org.eclipse.elk.nodeSize.constraints"] = "MINIMUM_SIZE, NODE_LABELS"; + options["org.eclipse.elk.nodeSize.minimum"] = "(30, 30)"; + options["org.eclipse.elk.spacing.labelNode"] = "20.0"; + break; + } + break; + case FTA_DESCRIPTION_NODE_TYPE: + options["org.eclipse.elk.nodeSize.constraints"] = "NODE_LABELS"; + break; + } + return options; + } + + protected portOptions(sport: FTAPort, _index: SModelIndex): LayoutOptions | undefined { + if (sport.type === FTA_PORT_TYPE) { + let side = ""; + if ((sport as FTAPort).side) { + side = PortSide[(sport as FTAPort).side!].toUpperCase(); + } + return { + "org.eclipse.elk.port.side": side, + }; + } } } diff --git a/extension/src-language-server/fta/diagram/fta-model.ts b/extension/src-language-server/fta/diagram/fta-model.ts index 0829f10c..bf961775 100644 --- a/extension/src-language-server/fta/diagram/fta-model.ts +++ b/extension/src-language-server/fta/diagram/fta-model.ts @@ -17,8 +17,11 @@ /* fault tree element types */ export const FTA_NODE_TYPE = "node:fta"; +export const FTA_DESCRIPTION_NODE_TYPE = "node:fta:description"; export const FTA_EDGE_TYPE = "edge:fta"; +export const FTA_INVISIBLE_EDGE_TYPE = "edge:fta:invisible"; export const FTA_GRAPH_TYPE = "graph:fta"; +export const FTA_PORT_TYPE = 'port:fta'; /** * Types of fault tree nodes. @@ -31,5 +34,15 @@ export enum FTNodeType { OR, KN, INHIBIT, + PARENT, UNDEFINED, } + + +/** Possible sides for a port. */ +export enum PortSide { + WEST, + EAST, + NORTH, + SOUTH +} diff --git a/extension/src-language-server/fta/fta-synthesis-options.ts b/extension/src-language-server/fta/diagram/fta-synthesis-options.ts similarity index 50% rename from extension/src-language-server/fta/fta-synthesis-options.ts rename to extension/src-language-server/fta/diagram/fta-synthesis-options.ts index df433e10..8ffc6dc6 100644 --- a/extension/src-language-server/fta/fta-synthesis-options.ts +++ b/extension/src-language-server/fta/diagram/fta-synthesis-options.ts @@ -15,15 +15,74 @@ * SPDX-License-Identifier: EPL-2.0 */ -import { DropDownOption, TransformationOptionType, ValuedSynthesisOption } from "../options/option-models"; -import { SynthesisOptions } from "../synthesis-options"; +import { DropDownOption, SynthesisOption, TransformationOptionType, ValuedSynthesisOption } from "../../options/option-models"; +import { SynthesisOptions, layoutCategory } from "../../synthesis-options"; const cutSetsID = "cutSets"; +const showGateDescriptionsID = "showGateDescriptions"; +const showComponentDescriptionsID = "showComponentDescriptions"; + +const analysisCategoryID = "analysisCategory"; export const noCutSet = { displayName: "---", id: "---" }; /* Single Point of Failure */ export const spofsSet = { displayName: "SPoFs", id: "SPoFs" }; +/** + * Category for analysis options. + */ +export const analysisCategory: SynthesisOption = { + id: analysisCategoryID, + name: "Analysis", + type: TransformationOptionType.CATEGORY, + initialValue: 0, + currentValue: 0, + values: [], +}; + +/** + * The option for the analysis category. + */ +const analysisCategoryOption: ValuedSynthesisOption = { + synthesisOption: analysisCategory, + currentValue: 0, +}; + +/** + * Boolean option to toggle the visualization gate descriptions. + */ +const showGateDescriptionsOptions: ValuedSynthesisOption = { + synthesisOption: { + id: showGateDescriptionsID, + name: "Show Gate Descriptions", + type: TransformationOptionType.CHECK, + initialValue: true, + currentValue: true, + values: [true, false], + category: layoutCategory, + }, + currentValue: true, +}; + +/** + * Boolean option to toggle the visualization node descriptions. + */ +const showComponentDescriptionsOptions: ValuedSynthesisOption = { + synthesisOption: { + id: showComponentDescriptionsID, + name: "Show Component Descriptions", + type: TransformationOptionType.CHECK, + initialValue: false, + currentValue: false, + values: [true, false], + category: layoutCategory, + }, + currentValue: false, +}; + +/** + * Option to highlight the components of a cut set. + */ const cutSets: ValuedSynthesisOption = { synthesisOption: { id: cutSetsID, @@ -34,6 +93,7 @@ const cutSets: ValuedSynthesisOption = { initialValue: noCutSet.id, currentValue: noCutSet.id, values: [noCutSet], + category: analysisCategory, } as DropDownOption, currentValue: noCutSet.id, }; @@ -42,7 +102,15 @@ export class FtaSynthesisOptions extends SynthesisOptions { protected spofs: string[]; constructor() { super(); - this.options = [cutSets]; + this.options.push(...[analysisCategoryOption, cutSets, showGateDescriptionsOptions, showComponentDescriptionsOptions]); + } + + getShowGateDescriptions(): boolean { + return this.getOption(showGateDescriptionsID)?.currentValue; + } + + getShowComponentDescriptions(): boolean { + return this.getOption(showComponentDescriptionsID)?.currentValue; } /** @@ -64,6 +132,21 @@ export class FtaSynthesisOptions extends SynthesisOptions { } } + /** + * Resets the cutSets option to no cut set. + */ + resetCutSets(): void { + const option = this.getOption(cutSetsID); + if (option) { + (option.synthesisOption as DropDownOption).availableValues = [noCutSet]; + (option.synthesisOption as DropDownOption).values = [noCutSet]; + (option.synthesisOption as DropDownOption).currentId = noCutSet.id; + option.synthesisOption.currentValue = noCutSet.id; + option.synthesisOption.initialValue = noCutSet.id; + option.currentValue = noCutSet.id; + } + } + setCutSet(value: string): void { const option = this.options.find((option) => option.synthesisOption.id === cutSetsID); if (option) { diff --git a/extension/src-language-server/fta/diagram/utils.ts b/extension/src-language-server/fta/diagram/utils.ts index 7cd51459..55ffd5a7 100644 --- a/extension/src-language-server/fta/diagram/utils.ts +++ b/extension/src-language-server/fta/diagram/utils.ts @@ -59,8 +59,11 @@ export function getFTNodeType(node: AstNode): FTNodeType { */ export function getTargets(node: AstNode): AstNode[] { const targets: AstNode[] = []; - // only the top event and gates have children - if (isTopEvent(node) || isGate(node)) { + // only the top event and gates have children/targets + if (isTopEvent(node) && node.child.ref) { + targets.push(node.child.ref); + } + if (isGate(node)) { for (const child of node.children) { if (child.ref) { targets.push(child.ref); diff --git a/extension/src-language-server/fta/fta-message-handler.ts b/extension/src-language-server/fta/fta-message-handler.ts index 0a2c6791..e8eff62d 100644 --- a/extension/src-language-server/fta/fta-message-handler.ts +++ b/extension/src-language-server/fta/fta-message-handler.ts @@ -17,11 +17,11 @@ import { LangiumSprottySharedServices } from "langium-sprotty"; import { Connection } from "vscode-languageserver"; +import { ModelFTA } from "../generated/ast"; import { getModel } from "../utils"; -import { determineMinimalCutSets, determineCutSetsForFT } from "./analysis/fta-cutSet-calculator"; +import { determineCutSetsForFT, determineMinimalCutSets } from "./analysis/fta-cutSet-calculator"; import { FtaServices } from "./fta-module"; -import { cutSetsToString } from "./utils"; -import { ModelFTA } from "../generated/ast"; +import { cutSetsToString, namedFtaElement } from "./utils"; /** * Adds handlers for notifications regarding fta. @@ -48,11 +48,14 @@ function addCutSetsHandler( ftaServices: FtaServices, sharedServices: LangiumSprottySharedServices ): void { - connection.onRequest("generate/getCutSets", async (uri: string) => { - return cutSetsRequested(uri, ftaServices, sharedServices, false); + connection.onRequest("cutSets/generate", async (content: { uri: string; startId?: string }) => { + return cutSetsRequested(content.uri, ftaServices, sharedServices, false, content.startId); + }); + connection.onRequest("cutSets/generateMinimal", async (content: { uri: string; startId?: string }) => { + return cutSetsRequested(content.uri, ftaServices, sharedServices, true, content.startId); }); - connection.onRequest("generate/getMinimalCutSets", async (uri: string) => { - return cutSetsRequested(uri, ftaServices, sharedServices, true); + connection.onRequest("cutSets/reset", () => { + return resetCutSets(ftaServices); }); } @@ -68,11 +71,16 @@ async function cutSetsRequested( uri: string, ftaServices: FtaServices, sharedServices: LangiumSprottySharedServices, - minimal: boolean + minimal: boolean, + startId?: string ): Promise { const model = (await getModel(uri, sharedServices)) as ModelFTA; - const nodes = [model.topEvent, ...model.components, ...model.conditions, ...model.gates]; - const cutSets = minimal ? determineMinimalCutSets(nodes) : determineCutSetsForFT(nodes); + const nodes: namedFtaElement[] = [...model.components, ...model.conditions, ...model.gates]; + if (model.topEvent) { + nodes.push(model.topEvent); + } + const startNode = startId ? nodes.find((node) => node.name === startId) : undefined; + const cutSets = minimal ? determineMinimalCutSets(nodes, startNode) : determineCutSetsForFT(nodes, startNode); // determine single points of failure const spofs: string[] = []; for (const cutSet of cutSets) { @@ -89,3 +97,8 @@ async function cutSetsRequested( ftaServices.options.SynthesisOptions.updateCutSetsOption(dropdownValues); return cutSetsString; } + +function resetCutSets(ftaServices: FtaServices): void { + ftaServices.options.SynthesisOptions.resetCutSets(); + return; +} diff --git a/extension/src-language-server/fta/fta-module.ts b/extension/src-language-server/fta/fta-module.ts index 76d0b4f7..5152c547 100644 --- a/extension/src-language-server/fta/fta-module.ts +++ b/extension/src-language-server/fta/fta-module.ts @@ -21,14 +21,14 @@ import { LangiumSprottyServices, SprottyDiagramServices } from "langium-sprotty" import { DefaultElementFilter, ElkFactory, - ElkLayoutEngine, IElementFilter, - ILayoutConfigurator, + ILayoutConfigurator } from "sprotty-elk/lib/elk-layout"; +import { LayoutEngine } from "../layout-engine"; import { FtaDiagramGenerator } from "./diagram/fta-diagram-generator"; import { FtaLayoutConfigurator } from "./diagram/fta-layout-config"; +import { FtaSynthesisOptions } from "./diagram/fta-synthesis-options"; import { FtaScopeProvider } from "./fta-scopeProvider"; -import { FtaSynthesisOptions } from "./fta-synthesis-options"; import { FtaValidationRegistry, FtaValidator } from "./fta-validator"; /** @@ -66,7 +66,7 @@ export const FtaModule: Module new FtaDiagramGenerator(services), ModelLayoutEngine: (services) => - new ElkLayoutEngine( + new LayoutEngine( services.layout.ElkFactory, services.layout.ElementFilter, services.layout.LayoutConfigurator diff --git a/extension/src-language-server/fta/fta.langium b/extension/src-language-server/fta/fta.langium index 40ae3944..50a885f3 100644 --- a/extension/src-language-server/fta/fta.langium +++ b/extension/src-language-server/fta/fta.langium @@ -3,7 +3,7 @@ grammar Fta entry ModelFTA: ('Components' components+=Component*)? ('Conditions' conditions+=Condition*)? - ('TopEvent' topEvent=TopEvent) + ('TopEvent' topEvent=TopEvent)? ('Gates' gates+=Gate*)?; Component: @@ -16,22 +16,22 @@ Gate: AND|OR|KNGate|InhibitGate; TopEvent: - name=STRING "=" children+=[Children:ID]; + name=STRING "=" child=[Children:ID]; Children: Gate | Component; AND: - name=ID "=" children+=[Children:ID] ('and' children+=[Children:ID])+; + name=ID (description=STRING)? "=" children+=[Children:ID] ('and' children+=[Children:ID])*; OR: - name=ID "=" children+=[Children:ID] ('or' children+=[Children:ID])+; + name=ID (description=STRING)? "=" children+=[Children:ID] ('or' children+=[Children:ID])*; KNGate: - name=ID "=" k=INT 'of' children+=[Children:ID] (',' children+=[Children:ID])+; + name=ID (description=STRING)? "=" k=INT 'of' children+=[Children:ID] (',' children+=[Children:ID])*; InhibitGate: - name=ID "=" condition+=[Condition:ID] 'inhibits' children+=[Children:ID]; + name=ID (description=STRING)? "=" condition+=[Condition:ID] 'inhibits' children+=[Children:ID]; hidden terminal WS: /\s+/; terminal ID: /[_a-zA-Z][\w_]*/; diff --git a/extension/src-language-server/fta/utils.ts b/extension/src-language-server/fta/utils.ts index 938a680b..2dc8fb84 100644 --- a/extension/src-language-server/fta/utils.ts +++ b/extension/src-language-server/fta/utils.ts @@ -16,8 +16,9 @@ */ import { Range } from "vscode-languageserver"; -import { Component, Condition, Gate, ModelFTA, TopEvent } from "../generated/ast"; +import { Component, Condition, Gate, ModelFTA, TopEvent, isAND, isInhibitGate, isKNGate, isOR } from "../generated/ast"; +/** FTA elements that have a name. */ export type namedFtaElement = Component | Condition | Gate | TopEvent; /** @@ -43,17 +44,56 @@ export function cutSetsToString(cutSets: Set[]): string[] { */ export function getRangeOfNodeFTA(model: ModelFTA, label: string): Range | undefined { let range: Range | undefined = undefined; - const elements: namedFtaElement[] = [ - model.topEvent, - ...model.components, - ...model.conditions, - ...model.gates - ]; - elements.forEach((component) => { - if (component.name === label) { - range = component.$cstNode?.range; - return; - } - }); + const elements: namedFtaElement[] = [...model.components, ...model.conditions, ...model.gates]; + if (model.topEvent) { + elements.push(model.topEvent); + } + const selectedElement = elements.find((element) => element.name === label); + if (selectedElement) { + range = selectedElement.$cstNode?.range; + } return range; -} \ No newline at end of file +} + +/** + * Serializes an FTA AST. + * @param model The model of the FTA AST to serialize. + * @returns the result of the serialization. + */ +export function serializeFTAAST(model: ModelFTA): string { + let result = ""; + if (model.components && model.components.length !== 0) { + result += "Components\n"; + model.components.forEach((component) => (result += `${component.name} "${component.description}"\n`)); + result += "\n"; + } + if (model.conditions && model.conditions.length !== 0) { + result += "Conditions\n"; + model.conditions.forEach((condition) => (result += `${condition.name} "${condition.description}"\n`)); + result += "\n"; + } + if (model.topEvent) { + result += "TopEvent\n"; + result += `"${model.topEvent.name}" = ${model.topEvent.child.$refText}\n`; + result += "\n"; + } + if (model.gates && model.gates.length !== 0) { + result += "Gates\n"; + model.gates.forEach((gate) => { + result += `${gate.name}`; + if (gate.description) { + result += ` "${gate.description}"`; + } + if (isAND(gate)) { + result += ` = ${gate.children.map(child => child.$refText).join(" and ")}\n`; + } else if (isOR(gate)) { + result += ` = ${gate.children.map(child => child.$refText).join(" or ")}\n`; + } else if (isKNGate(gate)) { + result += ` = ${gate.k} of ${gate.children.map(child => child.$refText).join(", ")}\n`; + } else if (isInhibitGate(gate)) { + result += ` = ${gate.condition} inhibits ${gate.children.map(child => child.$refText).join("")}\n`; + } + }); + } + return result; +} diff --git a/extension/src-language-server/layout-engine.ts b/extension/src-language-server/layout-engine.ts new file mode 100644 index 00000000..3fed65d5 --- /dev/null +++ b/extension/src-language-server/layout-engine.ts @@ -0,0 +1,76 @@ +/* + * KIELER - Kiel Integrated Environment for Layout Eclipse RichClient + * + * http://rtsys.informatik.uni-kiel.de/kieler + * + * Copyright 2021-2022 by + * + Kiel University + * + Department of Computer Science + * + Real-Time and Embedded Systems Group + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { ElkExtendedEdge, ElkNode, ElkPrimitiveEdge } from "elkjs/lib/elk-api"; +import { ElkLayoutEngine } from "sprotty-elk/lib/elk-layout"; +import { Point, SEdge, SGraph, SModelIndex } from "sprotty-protocol"; +import { FTAEdge } from "../src-webview/fta/fta-model"; +import { FTA_EDGE_TYPE } from "./fta/diagram/fta-model"; + +export class LayoutEngine extends ElkLayoutEngine { + layout(graph: SGraph, index?: SModelIndex | undefined): SGraph | Promise { + if (this.getBasicType(graph) !== "graph") { + return graph; + } + if (!index) { + index = new SModelIndex(); + index.add(graph); + } + const elkGraph = this.transformToElk(graph, index) as ElkNode; + /* used to inspect the elk graph in elklive */ + // const debugElkGraph = JSON.stringify(elkGraph); + // console.log(debugElkGraph); + return this.elk.layout(elkGraph).then((result) => { + this.applyLayout(result, index!); + return graph; + }); + } + + /** Override method to save the junctionpoints in FTAEdges*/ + protected applyEdge(sedge: SEdge, elkEdge: ElkExtendedEdge, index: SModelIndex): void { + const points: Point[] = []; + if (sedge.type === FTA_EDGE_TYPE) { + (sedge as any as FTAEdge).junctionPoints = elkEdge.junctionPoints; + } + if (elkEdge.sections && elkEdge.sections.length > 0) { + const section = elkEdge.sections[0]; + if (section.startPoint) { points.push(section.startPoint); } + if (section.bendPoints) { points.push(...section.bendPoints); } + if (section.endPoint) { points.push(section.endPoint); } + } else if (isPrimitiveEdge(elkEdge)) { + if (elkEdge.sourcePoint) { points.push(elkEdge.sourcePoint); } + if (elkEdge.bendPoints) { points.push(...elkEdge.bendPoints); } + if (elkEdge.targetPoint) { points.push(elkEdge.targetPoint); } + } + sedge.routingPoints = points; + + if (elkEdge.labels) { + elkEdge.labels.forEach((elkLabel) => { + const sLabel = elkLabel.id && index.getById(elkLabel.id); + if (sLabel) { + this.applyShape(sLabel, elkLabel, index); + } + }); + } + } +} + +function isPrimitiveEdge(edge: unknown): edge is ElkPrimitiveEdge { + return ( + typeof (edge as ElkPrimitiveEdge).source === "string" && typeof (edge as ElkPrimitiveEdge).target === "string" + ); +} diff --git a/extension/src-language-server/stpa/diagram/diagram-generator.ts b/extension/src-language-server/stpa/diagram/diagram-generator.ts index 82dbea2f..b470b51c 100644 --- a/extension/src-language-server/stpa/diagram/diagram-generator.ts +++ b/extension/src-language-server/stpa/diagram/diagram-generator.ts @@ -31,6 +31,7 @@ import { isSystemConstraint, isUCA, } from "../../generated/ast"; +import { getDescription } from "../../utils"; import { StpaServices } from "../stpa-module"; import { collectElementsWithSubComps, leafElement } from "../utils"; import { filterModel } from "./filtering"; @@ -48,7 +49,7 @@ import { STPA_NODE_TYPE, STPA_PORT_TYPE, } from "./stpa-model"; -import { StpaSynthesisOptions, labelManagementValue, showLabelsValue } from "./stpa-synthesis-options"; +import { StpaSynthesisOptions, showLabelsValue } from "./stpa-synthesis-options"; import { createUCAContextDescription, getAspect, getTargets, setLevelOfCSNodes, setLevelsForSTPANodes } from "./utils"; export class StpaDiagramGenerator extends LangiumDiagramGenerator { @@ -86,7 +87,7 @@ export class StpaDiagramGenerator extends LangiumDiagramGenerator { // subcomponents have edges to the parent const hazards = collectElementsWithSubComps(filteredModel.hazards); const sysCons = collectElementsWithSubComps(filteredModel.systemLevelConstraints); - stpaChildren = stpaChildren.concat([ + stpaChildren = stpaChildren?.concat([ ...hazards .map((sh) => this.generateAspectWithEdges( @@ -108,7 +109,7 @@ export class StpaDiagramGenerator extends LangiumDiagramGenerator { ]); } else { // subcomponents are contained in the parent - stpaChildren = stpaChildren.concat([ + stpaChildren = stpaChildren?.concat([ ...filteredModel.hazards ?.map((h) => this.generateAspectWithEdges( @@ -132,7 +133,7 @@ export class StpaDiagramGenerator extends LangiumDiagramGenerator { .flat(2), ]); } - stpaChildren = stpaChildren.concat([ + stpaChildren = stpaChildren?.concat([ ...filteredModel.responsibilities ?.map((r) => r.responsiblitiesForOneSystem.map((resp) => @@ -199,7 +200,7 @@ export class StpaDiagramGenerator extends LangiumDiagramGenerator { // filtering the nodes of the STPA graph const stpaNodes: STPANode[] = []; - for (const node of stpaChildren) { + for (const node of stpaChildren ?? []) { if (node.type === STPA_NODE_TYPE) { stpaNodes.push(node as STPANode); } @@ -296,7 +297,11 @@ export class StpaDiagramGenerator extends LangiumDiagramGenerator { * @param args GeneratorContext of the STPA model. * @returns A list of edges representing the commands. */ - protected translateCommandsToEdges(commands: VerticalEdge[], edgetype: EdgeType, args: GeneratorContext): CSEdge[] { + protected translateCommandsToEdges( + commands: VerticalEdge[], + edgetype: EdgeType, + args: GeneratorContext + ): CSEdge[] { const idCache = args.idCache; const edges: CSEdge[] = []; for (const edge of commands) { @@ -901,66 +906,22 @@ export class StpaDiagramGenerator extends LangiumDiagramGenerator { idCache: IdCache, nodeDescription?: string ): SModelElement[] { - const labelManagement = this.options.getLabelManagement(); - const children: SModelElement[] = []; + let children: SModelElement[] = []; //TODO: automatic label selection - if (nodeDescription && showDescription) { - const width = this.options.getLabelShorteningWidth(); - const words = nodeDescription.split(" "); - let current = ""; - switch (labelManagement) { - case labelManagementValue.NO_LABELS: - break; - case labelManagementValue.ORIGINAL: - // show complete description in one line - children.push({ - type: "label", - id: idCache.uniqueId(nodeId + ".label"), - text: nodeDescription, - }); - break; - case labelManagementValue.TRUNCATE: - // truncate description to the set value - if (words.length > 0) { - current = words[0]; - for (let i = 1; i < words.length && current.length + words[i].length <= width; i++) { - current += " " + words[i]; - } - children.push({ - type: "label", - id: idCache.uniqueId(nodeId + ".label"), - text: current + "...", - }); - } - break; - case labelManagementValue.WRAPPING: - // wrap description to the set value - const descriptions: string[] = []; - for (const word of words) { - if (current.length + word.length >= width) { - descriptions.push(current); - current = word; - } else { - current += " " + word; - } - } - descriptions.push(current); - for (let i = descriptions.length - 1; i >= 0; i--) { - children.push({ - type: "label", - id: idCache.uniqueId(nodeId + ".label"), - text: descriptions[i], - }); - } - break; - } + children = getDescription( + nodeDescription ?? "", + this.options.getLabelManagement(), + this.options.getLabelShorteningWidth(), + nodeId, + idCache + ); } // show the name in the top line children.push({ type: "label", - id: idCache.uniqueId(nodeId + ".label"), + id: idCache.uniqueId(nodeId + "_label"), text: nodeName, }); return children; diff --git a/extension/src-language-server/stpa/diagram/filtering.ts b/extension/src-language-server/stpa/diagram/filtering.ts index 16a52837..2db10f1a 100644 --- a/extension/src-language-server/stpa/diagram/filtering.ts +++ b/extension/src-language-server/stpa/diagram/filtering.ts @@ -135,12 +135,12 @@ function setFilterUCAOption(allUCAs: ActionUCAs[], rules: Rule[], options: StpaS const set = new Set(); set.add("all UCAs"); // collect all available control actions - allUCAs.forEach((uca) => { + allUCAs?.forEach((uca) => { if (!set.has(uca.system.ref?.name + "." + uca.action.ref?.name)) { set.add(uca.system.ref?.name + "." + uca.action.ref?.name); } }); - rules.forEach((rule) => { + rules?.forEach((rule) => { if (!set.has(rule.system.ref?.name + "." + rule.action.ref?.name)) { set.add(rule.system.ref?.name + "." + rule.action.ref?.name); } diff --git a/extension/src-language-server/stpa/diagram/layout-config.ts b/extension/src-language-server/stpa/diagram/layout-config.ts index f0d61bb3..7717d970 100644 --- a/extension/src-language-server/stpa/diagram/layout-config.ts +++ b/extension/src-language-server/stpa/diagram/layout-config.ts @@ -65,7 +65,7 @@ export class StpaLayoutConfigurator extends DefaultLayoutConfigurator { // model order is used to determine the order of the children if (snode.modelOrder) { - options['org.eclipse.elk.layered.nodePlacement.networkSimplex.nodeOrdering'] = 'NODES_AND_EDGES'; + options['org.eclipse.elk.layered.considerModelOrder.strategy'] = 'NODES_AND_EDGES'; options['org.eclipse.elk.layered.crossingMinimization.forceNodeModelOrder'] = 'true'; options['org.eclipse.elk.separateConnectedComponents'] = 'false'; } @@ -114,7 +114,7 @@ export class StpaLayoutConfigurator extends DefaultLayoutConfigurator { // model order is used to determine the order of the children if (node.modelOrder) { - options['org.eclipse.elk.layered.nodePlacement.networkSimplex.nodeOrdering'] = 'NODES_AND_EDGES'; + options['org.eclipse.elk.layered.considerModelOrder.strategy'] = 'NODES_AND_EDGES'; options['org.eclipse.elk.layered.crossingMinimization.forceNodeModelOrder'] = 'true'; options['org.eclipse.elk.separateConnectedComponents'] = 'false'; } diff --git a/extension/src-language-server/stpa/diagram/stpa-synthesis-options.ts b/extension/src-language-server/stpa/diagram/stpa-synthesis-options.ts index 111cc4a3..20002df7 100644 --- a/extension/src-language-server/stpa/diagram/stpa-synthesis-options.ts +++ b/extension/src-language-server/stpa/diagram/stpa-synthesis-options.ts @@ -17,15 +17,13 @@ import { DropDownOption, - RangeOption, SynthesisOption, TransformationOptionType, - ValuedSynthesisOption, + ValuedSynthesisOption } from "../../options/option-models"; -import { SynthesisOptions } from "../../synthesis-options"; +import { SynthesisOptions, layoutCategory } from "../../synthesis-options"; const hierarchyID = "hierarchy"; -const modelOrderID = "modelOrder"; const groupingUCAsID = "groupingUCAs"; export const filteringUCAsID = "filteringUCAs"; @@ -38,35 +36,12 @@ const hideUCAsID = "hideUCAs"; const hideSafetyConstraintsID = "hideSafetyConstraints"; const showLabelsID = "showLabels"; -const labelManagementID = "labelManagement"; -const labelShorteningWidthID = "labelShorteningWidth"; -const layoutCategoryID = "layoutCategory"; const filterCategoryID = "filterCategory"; const showControlStructureID = "showControlStructure"; const showRelationshipGraphID = "showRelationshipGraph"; -/** - * Category for layout options. - */ -const layoutCategory: SynthesisOption = { - id: layoutCategoryID, - name: "Layout", - type: TransformationOptionType.CATEGORY, - initialValue: 0, - currentValue: 0, - values: [], -}; - -/** - * The option for the layout category. - */ -const layoutCategoryOption: ValuedSynthesisOption = { - synthesisOption: layoutCategory, - currentValue: 0, -}; - /** * Category for filtering options. */ @@ -167,22 +142,6 @@ const hierarchicalGraphOption: ValuedSynthesisOption = { currentValue: true, }; -/** - * Boolean option to toggle model order. - */ -const modelOrderOption: ValuedSynthesisOption = { - synthesisOption: { - id: modelOrderID, - name: "Model Order", - type: TransformationOptionType.CHECK, - initialValue: true, - currentValue: true, - values: [true, false], - category: layoutCategory, - }, - currentValue: true, -}; - /** * Values for the grouping of UCAs. */ @@ -307,42 +266,6 @@ const hideScenariosWithHazardsOption: ValuedSynthesisOption = { currentValue: false, }; -/** - * Slider to set the desired width of label lines. - */ -const labelShorteningWidthOption: ValuedSynthesisOption = { - synthesisOption: { - id: labelShorteningWidthID, - name: "Shortening Width", - type: TransformationOptionType.RANGE, - initialValue: 30, - currentValue: 30, - range: { first: 0, second: 100 }, - stepSize: 1, - values: [], - category: layoutCategory, - } as RangeOption, - currentValue: 30, -}; - -/** - * Option to determine the display of node labels. - * It can be original labels (whole label in one line), wrapping (label is wrapped into multiple lines), - * truncate (label is truncated) or no labels. - */ -const labelManagementOption: ValuedSynthesisOption = { - synthesisOption: { - id: labelManagementID, - name: "Node Label Management", - type: TransformationOptionType.CHOICE, - initialValue: "Wrapping", - currentValue: "Wrapping", - values: ["Original Labels", "Wrapping", "Truncate", "No Labels"], - category: layoutCategory, - }, - currentValue: "Wrapping", -}; - /** * Option to filter the node labels based on the aspect of the node. */ @@ -372,16 +295,6 @@ const showLabelsOption: ValuedSynthesisOption = { currentValue: "losses", }; -/** - * Values for general the label management. - */ -export enum labelManagementValue { - ORIGINAL, - WRAPPING, - TRUNCATE, - NO_LABELS, -} - /** * Values for filtering the node labels. */ @@ -399,33 +312,26 @@ export enum showLabelsValue { } export class StpaSynthesisOptions extends SynthesisOptions { - constructor() { super(); - this.options = [ - layoutCategoryOption, - filterCategoryOption, - hierarchicalGraphOption, - modelOrderOption, - groupingOfUCAs, - filteringOfUCAs, - hideSysConsOption, - hideRespsOption, - hideUCAsOption, - hideContConsOption, - hideScenariosOption, - hideScenariosWithHazardsOption, - hideSafetyConstraintsOption, - labelManagementOption, - labelShorteningWidthOption, - showLabelsOption, - showControlStructureOption, - showRelationshipGraphOption, - ]; - } - - getModelOrder(): boolean { - return this.getOption(modelOrderID)?.currentValue; + this.options.push( + ...[ + filterCategoryOption, + hierarchicalGraphOption, + groupingOfUCAs, + filteringOfUCAs, + hideSysConsOption, + hideRespsOption, + hideUCAsOption, + hideContConsOption, + hideScenariosOption, + hideScenariosWithHazardsOption, + hideSafetyConstraintsOption, + showLabelsOption, + showControlStructureOption, + showRelationshipGraphOption, + ] + ); } getShowLabels(): showLabelsValue { @@ -455,25 +361,6 @@ export class StpaSynthesisOptions extends SynthesisOptions { return option?.currentValue; } - getLabelManagement(): labelManagementValue { - const option = this.options.find((option) => option.synthesisOption.id === labelManagementID); - switch (option?.currentValue) { - case "Original Labels": - return labelManagementValue.ORIGINAL; - case "Wrapping": - return labelManagementValue.WRAPPING; - case "Truncate": - return labelManagementValue.TRUNCATE; - case "No Labels": - return labelManagementValue.NO_LABELS; - } - return option?.currentValue; - } - - getLabelShorteningWidth(): number { - return this.getOption(labelShorteningWidthID)?.currentValue; - } - setShowRelationshipGraph(value: boolean): void { this.setOption(showRelationshipGraphID, value); } diff --git a/extension/src-language-server/stpa/ftaGeneration/fta-generation.ts b/extension/src-language-server/stpa/ftaGeneration/fta-generation.ts new file mode 100644 index 00000000..229dc65d --- /dev/null +++ b/extension/src-language-server/stpa/ftaGeneration/fta-generation.ts @@ -0,0 +1,148 @@ +/* + * KIELER - Kiel Integrated Environment for Layout Eclipse RichClient + * + * http://rtsys.informatik.uni-kiel.de/kieler + * + * Copyright 2023 by + * + Kiel University + * + Department of Computer Science + * + Real-Time and Embedded Systems Group + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import type { Reference } from "langium"; +import { LangiumSprottySharedServices } from "langium-sprotty"; +import { Children, Component, Hazard, LossScenario, Model, ModelFTA, OR, TopEvent } from "../../generated/ast"; +import { getModel } from "../../utils"; + +/** + * Create the fault trees for an stpa model as ASTs + * @param uri The uri of the stpa file for which the fault trees should be created. + * @param shared The shared services of Langium and Sprotty. + * @returns the ASTs of the created fault trees. + */ +export async function createFaultTrees(uri: string, shared: LangiumSprottySharedServices): Promise { + // get the current model + const model = (await getModel(uri, shared)) as Model; + const faultTrees: ModelFTA[] = []; + // sort scenarios based on their hazard + const scenarios: Map = sortScenarios(model); + // create fault tree for each hazard + for (const hazard of model.hazards) { + faultTrees.push(createFaulTreeForHazard(scenarios, hazard)); + } + return faultTrees; +} + +/** + * Sorts the loss scenarios in {@code model} based on their hazard. + * @param model The model containing the loss scenarios. + * @returns the sorted loss scenarios. + */ +function sortScenarios(model: Model): Map { + const scenarios: Map = new Map(); + for (const scenario of model.scenarios) { + const hazards = scenario.uca?.ref ? scenario.uca.ref.list.refs : scenario.list.refs; + for (const hazard of hazards || []) { + const hazardName = hazard.$refText; + addToListMap(scenarios, hazardName, scenario); + } + } + return scenarios; +} + +/** + * Creates a fault tree with {@code hazard} as top event. + * @param hazard The hazard for which the fault tree should be created. + * @returns the AST of the created fault tree with {@code hazard} as top event. + */ +function createFaulTreeForHazard(scenarios: Map, hazard: Hazard): ModelFTA { + const ftaModel = {} as ModelFTA; + ftaModel.components = []; + ftaModel.gates = []; + + // add scenarios as components and sort them by their causal factor + const causalFactors: Map = new Map(); + for (const scenario of scenarios.get(hazard.name) || []) { + const component = { + name: scenario.name, + description: scenario.description, + $container: ftaModel, + $type: "Component", + } as Component; + ftaModel.components.push(component); + const causalFactor = scenario.factor; + if (causalFactor) { + addToListMap(causalFactors, causalFactor, scenario); + } else { + addToListMap(causalFactors, "No causal factor", scenario); + } + } + + // create gate for each causal factor + let counter = 1; + for (const causalFactor of causalFactors.keys()) { + const children = causalFactors.get(causalFactor)?.map((scenario) => { + return { + ref: ftaModel.components.find((component) => component.name === scenario.name), + $refText: scenario.name, + } as Reference; + }); + if (children) { + const gate = { + name: `G${counter}`, + description: causalFactor, + children, + $container: ftaModel, + $type: "OR", + } as OR; + ftaModel.gates.push(gate); + counter++; + } + } + + // create gate to connect top event with all other gates + const gateChildren = ftaModel.gates.map((gate) => { + return { ref: gate, $refText: gate.name } as Reference; + }); + const gate = { + name: `G0`, + children: gateChildren, + $container: ftaModel, + $type: "OR", + } as OR; + ftaModel.gates.push(gate); + + // create top event + const topEvent = { + name: hazard.description, + child: { ref: gate, $refText: gate.name }, + $container: ftaModel, + $type: "TopEvent", + } as TopEvent; + ftaModel.topEvent = topEvent; + + return ftaModel; +} + +/** + * Adds {@code value} to the list of {@code map} at {@code key}. + * @param map The map to which the value should be added. + * @param key The key at which the value should be added. + * @param value The value which should be added. + */ +function addToListMap(map: Map, key: string, value: any): void { + if (map.has(key)) { + const currentValues = map.get(key); + if (currentValues) { + currentValues.push(value); + } + } else { + map.set(key, [value]); + } +} diff --git a/extension/src-language-server/stpa/message-handler.ts b/extension/src-language-server/stpa/message-handler.ts index 71270194..8f5094a8 100644 --- a/extension/src-language-server/stpa/message-handler.ts +++ b/extension/src-language-server/stpa/message-handler.ts @@ -20,6 +20,8 @@ import { LangiumSprottySharedServices } from "langium-sprotty"; import { TextDocumentContentChangeEvent } from "vscode"; import { Connection, URI } from "vscode-languageserver"; import { diagramSizes } from "../diagram-server"; +import { serializeFTAAST } from "../fta/utils"; +import { createFaultTrees } from "./ftaGeneration/fta-generation"; import { generateLTLFormulae } from "./modelChecking/model-checking"; import { createResultData } from "./result-report/result-generator"; import { StpaServices } from "./stpa-module"; @@ -46,6 +48,7 @@ export function addSTPANotificationHandler( addTextChangeHandler(connection, stpaServices, sharedServices); addVerificationHandler(connection, sharedServices); addResultHandler(connection, sharedServices); + addFTAGeneratorHandler(connection, sharedServices); } /** @@ -153,3 +156,16 @@ function addResultHandler(connection: Connection, sharedServices: LangiumSprotty return diagramSizes; }); } + +/** + * Adds handlers for requests regarding the fault tree creation. + * @param connection + */ +function addFTAGeneratorHandler(connection: Connection, sharedServices: LangiumSprottySharedServices): void { + // creates and serializes fault trees + connection.onRequest("generate/faultTrees", async (uri: string) => { + const models = await createFaultTrees(uri, sharedServices); + const texts = models.map((model) => serializeFTAAST(model)); + return texts; + }); +} diff --git a/extension/src-language-server/stpa/stpa-module.ts b/extension/src-language-server/stpa/stpa-module.ts index 7150f6bb..e6581f8b 100644 --- a/extension/src-language-server/stpa/stpa-module.ts +++ b/extension/src-language-server/stpa/stpa-module.ts @@ -27,10 +27,10 @@ import { import { DefaultElementFilter, ElkFactory, - ElkLayoutEngine, IElementFilter, - ILayoutConfigurator, + ILayoutConfigurator } from "sprotty-elk/lib/elk-layout"; +import { LayoutEngine } from "../layout-engine"; import { IDEnforcer } from "./ID-enforcer"; import { ContextTableProvider } from "./contextTable/context-dataProvider"; import { StpaDiagramGenerator } from "./diagram/diagram-generator"; @@ -80,7 +80,7 @@ export const STPAModule: Module new StpaDiagramGenerator(services), ModelLayoutEngine: (services) => - new ElkLayoutEngine( + new LayoutEngine( services.layout.ElkFactory, services.layout.ElementFilter, services.layout.LayoutConfigurator diff --git a/extension/src-language-server/stpa/stpa.langium b/extension/src-language-server/stpa/stpa.langium index 63a8c5c5..e54c4306 100644 --- a/extension/src-language-server/stpa/stpa.langium +++ b/extension/src-language-server/stpa/stpa.langium @@ -127,8 +127,14 @@ ControllerConstraint: name=ID description=STRING '['refs+=[UCA] (',' refs+=[UCA])*']'; LossScenario: - (name=ID description=STRING list=HazardList) | - (name=ID 'for' uca=[UCA] description=STRING (list=HazardList)?); + (name=ID ('<' factor=CausalFactor '>')? description=STRING list=HazardList) | + (name=ID ('<' factor=CausalFactor '>')? 'for' uca=[UCA] description=STRING (list=HazardList)?); + +CausalFactor returns string: + 'controlAction' | 'inadequateOperation' | 'delayedOperation' | 'componentFailure' | 'changesOverTime' | + 'conflictingCA' | 'wrongProcessInput' | 'disturbance' | 'processOutput' | 'delayedFeedback' | + 'measurementInaccurate' | 'incorrectInformationProvided' | 'missingFeedback' | 'inadequateControlAlgorithm' | + 'wrongProcessModel' | 'wrongControlInput'; HazardList: '[' refs+=[Hazard:SubID] (',' refs+=[Hazard:SubID])*']'; diff --git a/extension/src-language-server/synthesis-options.ts b/extension/src-language-server/synthesis-options.ts index d81c391c..48212797 100644 --- a/extension/src-language-server/synthesis-options.ts +++ b/extension/src-language-server/synthesis-options.ts @@ -15,13 +15,105 @@ * SPDX-License-Identifier: EPL-2.0 */ -import { ValuedSynthesisOption } from "./options/option-models"; +import { RangeOption, SynthesisOption, TransformationOptionType, ValuedSynthesisOption } from "./options/option-models"; + +const labelManagementID = "labelManagement"; +const labelShorteningWidthID = "labelShorteningWidth"; +const modelOrderID = "modelOrder"; + +const layoutCategoryID = "layoutCategory"; + +/** + * Category for layout options. + */ +export const layoutCategory: SynthesisOption = { + id: layoutCategoryID, + name: "Layout", + type: TransformationOptionType.CATEGORY, + initialValue: 0, + currentValue: 0, + values: [], +}; + +/** + * The option for the layout category. + */ +const layoutCategoryOption: ValuedSynthesisOption = { + synthesisOption: layoutCategory, + currentValue: 0, +}; + +/** + * Slider to set the desired width of label lines. + */ +const labelShorteningWidthOption: ValuedSynthesisOption = { + synthesisOption: { + id: labelShorteningWidthID, + name: "Shortening Width", + type: TransformationOptionType.RANGE, + initialValue: 30, + currentValue: 30, + range: { first: 0, second: 100 }, + stepSize: 1, + values: [], + category: layoutCategory, + } as RangeOption, + currentValue: 30, +}; + +/** + * Option to determine the display of node labels. + * It can be original labels (whole label in one line), wrapping (label is wrapped into multiple lines), + * truncate (label is truncated) or no labels. + */ +const labelManagementOption: ValuedSynthesisOption = { + synthesisOption: { + id: labelManagementID, + name: "Node Label Management", + type: TransformationOptionType.CHOICE, + initialValue: "Wrapping", + currentValue: "Wrapping", + values: ["Original Labels", "Wrapping", "Truncate", "No Labels"], + category: layoutCategory, + }, + currentValue: "Wrapping", +}; + +/** + * Boolean option to toggle model order. + */ +const modelOrderOption: ValuedSynthesisOption = { + synthesisOption: { + id: modelOrderID, + name: "Model Order", + type: TransformationOptionType.CHECK, + initialValue: true, + currentValue: true, + values: [true, false], + category: layoutCategory, + }, + currentValue: true, +}; + +/** + * Values for general the label management. + */ +export enum labelManagementValue { + ORIGINAL, + WRAPPING, + TRUNCATE, + NO_LABELS, +} export class SynthesisOptions { protected options: ValuedSynthesisOption[]; constructor() { - this.options = []; + this.options = [ + layoutCategoryOption, + labelManagementOption, + labelShorteningWidthOption, + modelOrderOption]; } getSynthesisOptions(): ValuedSynthesisOption[] { @@ -32,4 +124,27 @@ export class SynthesisOptions { const option = this.options.find((option) => option.synthesisOption.id === id); return option; } -} \ No newline at end of file + + getLabelManagement(): labelManagementValue { + const option = this.options.find((option) => option.synthesisOption.id === labelManagementID); + switch (option?.currentValue) { + case "Original Labels": + return labelManagementValue.ORIGINAL; + case "Wrapping": + return labelManagementValue.WRAPPING; + case "Truncate": + return labelManagementValue.TRUNCATE; + case "No Labels": + return labelManagementValue.NO_LABELS; + } + return option?.currentValue; + } + + getLabelShorteningWidth(): number { + return this.getOption(labelShorteningWidthID)?.currentValue; + } + + getModelOrder(): boolean { + return this.getOption(modelOrderID)?.currentValue; + } +} diff --git a/extension/src-language-server/utils.ts b/extension/src-language-server/utils.ts index 69fcc52e..b2f6f805 100644 --- a/extension/src-language-server/utils.ts +++ b/extension/src-language-server/utils.ts @@ -16,8 +16,10 @@ */ import { AstNode, LangiumSharedServices } from "langium"; -import { LangiumSprottySharedServices } from "langium-sprotty"; +import { IdCache, LangiumSprottySharedServices } from "langium-sprotty"; import { URI } from "vscode-uri"; +import { labelManagementValue } from "./synthesis-options"; +import { SLabel } from 'sprotty-protocol'; /** * Determines the model for {@code uri}. @@ -33,3 +35,71 @@ export async function getModel( const currentDoc = textDocuments.getOrCreateDocument(URI.parse(uri)); return currentDoc.parseResult.value; } + +/** + * Creates a list of labels containing the given {@code description} respecting the {@code labelManagement} and {@code labelWidth}. + * @param description The text for the label to create. + * @param labelManagement The label management option. + * @param labelWidth The desired width of the label. + * @param nodeId The id of the node for which the label is created. + * @param idCache The id cache. + * @returns a list of labels containing the given {@code description} respecting the {@code labelManagement} and {@code labelWidth}. + */ +export function getDescription( + description: string, + labelManagement: labelManagementValue, + labelWidth: number, + nodeId: string, + idCache: IdCache +): SLabel[] { + const labels: SLabel[] = []; + const words = description.split(" "); + let current = ""; + switch (labelManagement) { + case labelManagementValue.NO_LABELS: + break; + case labelManagementValue.ORIGINAL: + // show complete description in one line + labels.push({ + type: "label", + id: idCache.uniqueId(nodeId + "_label"), + text: description, + }); + break; + case labelManagementValue.TRUNCATE: + // truncate description to the set value + if (words.length > 0) { + current = words[0]; + for (let i = 1; i < words.length && current.length + words[i].length <= labelWidth; i++) { + current += " " + words[i]; + } + labels.push({ + type: "label", + id: idCache.uniqueId(nodeId + "_label"), + text: current + "...", + }); + } + break; + case labelManagementValue.WRAPPING: + // wrap description to the set value + const descriptions: string[] = []; + for (const word of words) { + if (current.length + word.length >= labelWidth) { + descriptions.push(current); + current = word; + } else { + current += " " + word; + } + } + descriptions.push(current); + for (let i = descriptions.length - 1; i >= 0; i--) { + labels.push({ + type: "label", + id: idCache.uniqueId(nodeId + "_label"), + text: descriptions[i], + }); + } + break; + } + return labels; +} diff --git a/extension/src-webview/actions.ts b/extension/src-webview/actions.ts index 747d5306..139c597d 100644 --- a/extension/src-webview/actions.ts +++ b/extension/src-webview/actions.ts @@ -17,7 +17,7 @@ import { inject } from "inversify"; import { CommandExecutionContext, CommandResult, HiddenCommand, TYPES, isExportable, isHoverable, isSelectable, isViewport } from "sprotty"; -import { RequestAction, ResponseAction, generateRequestId } from "sprotty-protocol"; +import { Action, RequestAction, ResponseAction, generateRequestId } from "sprotty-protocol"; /** Requests the current SVG from the client. */ @@ -91,4 +91,36 @@ export class SvgCommand extends HiddenCommand { modelChanged: false }; } -} \ No newline at end of file +} + +/** Send from client to server to start a cut set analysis with the start node given by the startId */ +export interface CutSetAnalysisAction extends Action { + kind: typeof CutSetAnalysisAction.KIND; + startId: string +} +export namespace CutSetAnalysisAction { + export const KIND = 'cutSetAnalysis'; + + export function create(startId: string,): CutSetAnalysisAction { + return { + kind: KIND, + startId, + }; + } +} + +/** Send from client to server to start a minimal cut set analysis with the start node given by the startId */ +export interface MinimalCutSetAnalysisAction extends Action { + kind: typeof MinimalCutSetAnalysisAction.KIND; + startId: string +} +export namespace MinimalCutSetAnalysisAction { + export const KIND = 'minimalCutSetAnalysis'; + + export function create(startId: string,): MinimalCutSetAnalysisAction { + return { + kind: KIND, + startId, + }; + } +} diff --git a/extension/src-webview/context-menu/context-menu-mouse-listener.ts b/extension/src-webview/context-menu/context-menu-mouse-listener.ts new file mode 100644 index 00000000..926e42f6 --- /dev/null +++ b/extension/src-webview/context-menu/context-menu-mouse-listener.ts @@ -0,0 +1,30 @@ +/* + * KIELER - Kiel Integrated Environment for Layout Eclipse RichClient + * + * http://rtsys.informatik.uni-kiel.de/kieler + * + * Copyright 2023 by + * + Kiel University + * + Department of Computer Science + * + Real-Time and Embedded Systems Group + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + */ + + +import { ContextMenuMouseListener, SLabel, SModelElement } from "sprotty"; + +export class PastaContextMenuMouseListener extends ContextMenuMouseListener { + + protected async showContextMenu(target: SModelElement, event: MouseEvent): Promise { + if (target instanceof SLabel) { + super.showContextMenu(target.parent, event); + } else { + super.showContextMenu(target, event); + } + } +} diff --git a/extension/src-webview/context-menu/context-menu-provider.ts b/extension/src-webview/context-menu/context-menu-provider.ts new file mode 100644 index 00000000..2f68df95 --- /dev/null +++ b/extension/src-webview/context-menu/context-menu-provider.ts @@ -0,0 +1,58 @@ +/* + * KIELER - Kiel Integrated Environment for Layout Eclipse RichClient + * + * http://rtsys.informatik.uni-kiel.de/kieler + * + * Copyright 2023 by + * + Kiel University + * + Department of Computer Science + * + Real-Time and Embedded Systems Group + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { injectable } from "inversify"; +import { IContextMenuItemProvider, LabeledAction, SModelRoot } from "sprotty"; +import { Point } from "sprotty-protocol"; +import { FTANode, FTA_GRAPH_TYPE, FTA_NODE_TYPE } from "../fta/fta-model"; +import { CutSetAnalysisAction, MinimalCutSetAnalysisAction } from "../actions"; + +@injectable() +export class ContextMenuProvider implements IContextMenuItemProvider { + getItems(root: Readonly, _lastMousePosition?: Point | undefined): Promise { + if (root.type === FTA_GRAPH_TYPE) { + // find node that was clicked on + let clickedNode: FTANode | undefined; + root.children.forEach((child) => { + if (child.type === FTA_NODE_TYPE) { + if ((child as FTANode).selected) { + clickedNode = child as FTANode; + } else { + const children = child.children.filter((child) => child.type === FTA_NODE_TYPE); + const selectedChild = children.find(child => (child as FTANode).selected); + if (selectedChild) { + clickedNode = selectedChild as FTANode; + } + } + } + }); + // create context menu items + return Promise.resolve([ + { + label: "Cut Set Analysis", + actions: [{ kind: "cutSetAnalysis", startId: clickedNode?.id } as CutSetAnalysisAction], + } as LabeledAction, + { + label: "Minimal Cut Set Analysis", + actions: [{ kind: "minimalCutSetAnalysis", startId: clickedNode?.id} as MinimalCutSetAnalysisAction] + } as LabeledAction + ]); + } else { + return Promise.resolve([]); + } + } +} diff --git a/extension/src-webview/context-menu/context-menu-services.ts b/extension/src-webview/context-menu/context-menu-services.ts new file mode 100644 index 00000000..7722b5eb --- /dev/null +++ b/extension/src-webview/context-menu/context-menu-services.ts @@ -0,0 +1,124 @@ +/* + * KIELER - Kiel Integrated Environment for Layout Eclipse RichClient + * + * http://rtsys.informatik.uni-kiel.de/kieler + * + * Copyright 2023 by + * + Kiel University + * + Department of Computer Science + * + Real-Time and Embedded Systems Group + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { inject, injectable } from "inversify"; +import { Anchor, IContextMenuService, MenuItem } from "sprotty"; +import { ActionNotification } from "sprotty-vscode-protocol"; +import { VsCodeMessenger } from "sprotty-vscode-webview/lib/services"; +import { HOST_EXTENSION } from "vscode-messenger-common"; +import { Messenger } from "vscode-messenger-webview"; + +@injectable() +export class ContextMenuService implements IContextMenuService { + @inject(VsCodeMessenger) protected messenger: Messenger; + /* The id of the context menu. */ + protected contextMenuID = "contextMenu"; + + show(items: MenuItem[], anchor: Anchor, onHide?: (() => void) | undefined): void { + // create or get the context menu + const menu = this.getOrCreateContextMenu(onHide); + + // add the items to the menu + for (const item of items) { + this.addItemToContextMenu(menu, item); + } + + // position the context menu + menu.style.left = anchor.x.toString() + "px"; + menu.style.top = anchor.y.toString() + "px"; + + const window_height = menu.parentElement!.offsetHeight; + const window_width = menu.parentElement!.offsetWidth; + // if the context menu would be partially outside the view, we relocate it so it fits inside + if (menu.offsetHeight + menu.offsetTop > window_height) { + menu.style.top = (window_height - menu.offsetHeight).toString() + "px"; + } + if (menu.offsetWidth + menu.offsetLeft > window_width) { + menu.style.left = (window_width - menu.offsetWidth).toString() + "px"; + } + } + + /** + * Creates a context menu with the "contextMenuID" if it does not exist yet, and adds it to the DOM. + * Otherwise it returns the existing one. + * @returns the context menu with the "contextMenuID". + */ + protected getOrCreateContextMenu(onHide?: () => void): HTMLElement { + let menu = document.getElementById(this.contextMenuID); + if (menu === null) { + // creates the context menu + menu = document.createElement("ul"); + menu.id = this.contextMenuID; + menu.classList.add("context-menu"); + + // if the context menu is left, we hide it + menu.addEventListener("mouseleave", () => { + if (onHide !== undefined) { + onHide(); + } + if (menu !== null) { + menu.classList.add("hidden"); + } + }); + + // adds the context menu to the dom + const sprotty = document.getElementsByClassName("sprotty"); + if (sprotty.length !== 0) { + sprotty[0].appendChild(menu); + } else { + console.log("Context menu could not be added to the DOM."); + return menu; + } + } else { + // reset the menu + menu.innerHTML = ""; + menu.classList.remove("hidden"); + } + return menu; + } + + /** + * Creates a DOM element for the given {@code item} and adds it to the given {@code menu}. + * @param menu The menu to which the item should be added. + * @param item The item to be added to the menu. + */ + protected addItemToContextMenu(menu: HTMLElement, item: MenuItem): void { + // creates the dom element for the item + const domItem = document.createElement("li"); + domItem.classList.add("context-menu-item"); + domItem.innerText = item.label; + domItem.id = item.label; + + // highlights the item when the mouse is over it + domItem.addEventListener("mouseenter", () => { + domItem.classList.add("selected"); + }); + domItem.addEventListener("mouseleave", () => { + domItem.classList.remove("selected"); + }); + + // executes the action when the item is clicked + domItem.addEventListener("click", () => { + for (const action of item.actions ?? []) { + this.messenger.sendNotification(ActionNotification, HOST_EXTENSION, { clientId: "", action: action }); + } + }); + + // append the item to the menu + menu.appendChild(domItem); + } +} diff --git a/extension/src-webview/context-menu/di.config.ts b/extension/src-webview/context-menu/di.config.ts new file mode 100644 index 00000000..68ae2635 --- /dev/null +++ b/extension/src-webview/context-menu/di.config.ts @@ -0,0 +1,40 @@ +/* + * KIELER - Kiel Integrated Environment for Layout Eclipse RichClient + * + * http://rtsys.informatik.uni-kiel.de/kieler + * + * Copyright 2023 by + * + Kiel University + * + Department of Computer Science + * + Real-Time and Embedded Systems Group + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { ContainerModule } from "inversify"; +import { ContextMenuProviderRegistry, IContextMenuService, TYPES } from "sprotty"; +import { PastaContextMenuMouseListener } from "./context-menu-mouse-listener"; + +const pastaContextMenuModule = new ContainerModule(bind => { + bind(TYPES.IContextMenuServiceProvider).toProvider(ctx => { + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + return () => { + return new Promise((resolve, reject) => { + if (ctx.container.isBound(TYPES.IContextMenuService)) { + resolve(ctx.container.get(TYPES.IContextMenuService)); + } else { + reject(); + } + }); + }; + }); + bind(PastaContextMenuMouseListener).toSelf().inSingletonScope(); + bind(TYPES.MouseListener).toService(PastaContextMenuMouseListener); + bind(TYPES.IContextMenuProviderRegistry).to(ContextMenuProviderRegistry); +}); + +export default pastaContextMenuModule; diff --git a/extension/src-webview/css/context-menu.css b/extension/src-webview/css/context-menu.css new file mode 100644 index 00000000..c9f1a931 --- /dev/null +++ b/extension/src-webview/css/context-menu.css @@ -0,0 +1,32 @@ +.context-menu { + margin-top: -1px; + margin-left: -1px; + background-color: var(--vscode-menu-background); + border: 1px solid var(--vscode-menu-border); + border-radius: 5px; + float: right; + position: absolute; + list-style: none; + padding: 4px; + color: var(--vscode-menu-foreground); +} + +.context-menu.hidden { + display: none; +} + +.context-menu-item { + display: block; + position: relative; + padding: 5px; + cursor: pointer; +} + +.context-menu-item.selected { + background-color: var(--vscode-menu-selectionBackground); + /* border: 1px solid ; */ + border-radius: 5px; + color: var(--vscode-menu-selectionForeground); +} + +/* --vscode-menu-separatorBackground: #d4d4d4; */ diff --git a/extension/src-webview/css/diagram.css b/extension/src-webview/css/diagram.css index ed2f200b..d1b36d33 100644 --- a/extension/src-webview/css/diagram.css +++ b/extension/src-webview/css/diagram.css @@ -3,6 +3,7 @@ @import "./theme.css"; @import "./stpa-diagram.css"; @import "./fta-diagram.css"; +@import "./context-menu.css"; /* sprotty and black/white colors */ .vscode-high-contrast .print-node { @@ -64,12 +65,14 @@ body[class='vscode-light'] .sprotty-label { fill: black; stroke-width: 0; + cursor: default; } .sprotty-label { fill: var(--vscode-editorActiveLineNumber-foreground); stroke-width: 0; font-family: Arial, sans-serif; + cursor: default; } .sprotty-button { diff --git a/extension/src-webview/css/fta-diagram.css b/extension/src-webview/css/fta-diagram.css index edb0f761..cf3b96b8 100644 --- a/extension/src-webview/css/fta-diagram.css +++ b/extension/src-webview/css/fta-diagram.css @@ -41,8 +41,27 @@ fill: dimgrey; stroke-width: 0; font-size: 10px; + cursor: default; } .fta-highlight-node{ stroke: var(--highlight-node); } + +.gate-description { + stroke: none; + fill: darkgrey; + fill-opacity: 20%; +} + +.vertical-edge { + stroke: lightgray; +} + +.description-border { + stroke: dimgray; +} + +.top-event { + stroke: dodgerblue; +} diff --git a/extension/src-webview/di.config.ts b/extension/src-webview/di.config.ts index 1d49fee0..eff02c2d 100644 --- a/extension/src-webview/di.config.ts +++ b/extension/src-webview/di.config.ts @@ -34,14 +34,18 @@ import { TYPES, configureCommand, configureModelElement, + contextMenuModule, loadDefaultModules, - overrideViewerOptions, + overrideViewerOptions } from "sprotty"; import { SvgCommand } from "./actions"; +import { ContextMenuProvider } from "./context-menu/context-menu-provider"; +import { ContextMenuService } from "./context-menu/context-menu-services"; +import pastaContextMenuModule from "./context-menu/di.config"; import { SvgPostprocessor } from "./exportPostProcessor"; import { CustomSvgExporter } from "./exporter"; -import { FTAEdge, FTANode, FTA_EDGE_TYPE, FTA_GRAPH_TYPE, FTA_NODE_TYPE } from "./fta/fta-model"; -import { FTAGraphView, FTANodeView, PolylineArrowEdgeViewFTA } from "./fta/fta-views"; +import { DescriptionNode, FTAEdge, FTAGraph, FTANode, FTAPort, FTA_DESCRIPTION_NODE_TYPE, FTA_EDGE_TYPE, FTA_GRAPH_TYPE, FTA_INVISIBLE_EDGE_TYPE, FTA_NODE_TYPE, FTA_PORT_TYPE } from "./fta/fta-model"; +import { DescriptionNodeView, FTAGraphView, FTAInvisibleEdgeView, FTANodeView, PolylineArrowEdgeViewFTA } from "./fta/fta-views"; import { PastaModelViewer } from "./model-viewer"; import { optionsModule } from "./options/options-module"; import { sidebarModule } from "./sidebar"; @@ -84,6 +88,9 @@ const pastaDiagramModule = new ContainerModule((bind, unbind, isBound, rebind) = bind(SvgPostprocessor).toSelf().inSingletonScope(); bind(TYPES.HiddenVNodePostprocessor).toService(SvgPostprocessor); configureCommand({ bind, isBound }, SvgCommand); + // context-menu + bind(TYPES.IContextMenuService).to(ContextMenuService); + bind(TYPES.IContextMenuItemProvider).to(ContextMenuProvider); // configure the diagram elements const context = { bind, unbind, isBound, rebind }; @@ -105,14 +112,17 @@ const pastaDiagramModule = new ContainerModule((bind, unbind, isBound, rebind) = // FTA configureModelElement(context, FTA_EDGE_TYPE, FTAEdge, PolylineArrowEdgeViewFTA); + configureModelElement(context, FTA_INVISIBLE_EDGE_TYPE, FTAEdge, FTAInvisibleEdgeView); configureModelElement(context, FTA_NODE_TYPE, FTANode, FTANodeView); - configureModelElement(context, FTA_GRAPH_TYPE, SGraph, FTAGraphView); + configureModelElement(context, FTA_DESCRIPTION_NODE_TYPE, DescriptionNode, DescriptionNodeView); + configureModelElement(context, FTA_GRAPH_TYPE, FTAGraph, FTAGraphView); + configureModelElement(context, FTA_PORT_TYPE, FTAPort, PortView); }); export function createPastaDiagramContainer(widgetId: string): Container { const container = new Container(); - loadDefaultModules(container); - container.load(pastaDiagramModule, sidebarModule, optionsModule); + loadDefaultModules(container, {exclude: [contextMenuModule]}); + container.load(pastaContextMenuModule, pastaDiagramModule, sidebarModule, optionsModule); overrideViewerOptions(container, { needsClientLayout: true, needsServerLayout: true, diff --git a/extension/src-webview/fta/fta-model.ts b/extension/src-webview/fta/fta-model.ts index 5bb3ef29..9df301b7 100644 --- a/extension/src-webview/fta/fta-model.ts +++ b/extension/src-webview/fta/fta-model.ts @@ -16,20 +16,26 @@ */ import { + Point, SEdge, + SGraph, SNode, + SPort, connectableFeature, fadeFeature, hoverFeedbackFeature, layoutContainerFeature, popupFeature, - selectFeature, + selectFeature } from "sprotty"; /* fault tree element types */ export const FTA_NODE_TYPE = "node:fta"; +export const FTA_DESCRIPTION_NODE_TYPE = "node:fta:description"; export const FTA_EDGE_TYPE = "edge:fta"; +export const FTA_INVISIBLE_EDGE_TYPE = "edge:fta:invisible"; export const FTA_GRAPH_TYPE = "graph:fta"; +export const FTA_PORT_TYPE = "port:fta"; /** * Node of a fault tree. @@ -47,17 +53,49 @@ export class FTANode extends SNode { name: string; nodeType: FTNodeType = FTNodeType.UNDEFINED; description: string = ""; + topOfAnalysis?: boolean; inCurrentSelectedCutSet?: boolean; notConnectedToSelectedCutSet?: boolean; k?: number; n?: number; } +/** + * FTA Graph. + */ +export class FTAGraph extends SGraph { + modelOrder?: boolean; +} + +/** + * Description node of a fault tree. + */ +export class DescriptionNode extends SNode { + static readonly DEFAULT_FEATURES = [ + connectableFeature, + selectFeature, + layoutContainerFeature, + fadeFeature, + hoverFeedbackFeature, + popupFeature, + ]; + + name: string; + inCurrentSelectedCutSet?: boolean; + notConnectedToSelectedCutSet?: boolean; +} + /** * Edge of a fault tree. */ export class FTAEdge extends SEdge { notConnectedToSelectedCutSet?: boolean; + junctionPoints?: Point[]; +} + +/** Port representing a port in the FTA graph. */ +export class FTAPort extends SPort { + side?: PortSide; } /** @@ -71,5 +109,14 @@ export enum FTNodeType { OR, KN, INHIBIT, + PARENT, UNDEFINED, } + +/** Possible sides for a port. */ +export enum PortSide { + WEST, + EAST, + NORTH, + SOUTH, +} diff --git a/extension/src-webview/fta/fta-views.tsx b/extension/src-webview/fta/fta-views.tsx index 16215799..6e2360ad 100644 --- a/extension/src-webview/fta/fta-views.tsx +++ b/extension/src-webview/fta/fta-views.tsx @@ -18,9 +18,9 @@ /** @jsx svg */ import { injectable } from 'inversify'; import { VNode } from "snabbdom"; -import { Point, PolylineEdgeView, RectangularNodeView, RenderingContext, SGraph, SGraphView, svg } from 'sprotty'; -import { renderAndGate, renderOval, renderInhibitGate, renderKnGate, renderOrGate, renderRectangle } from "../views-rendering"; -import { FTAEdge, FTANode, FTNodeType } from './fta-model'; +import { IViewArgs, Point, PolylineEdgeView, RectangularNodeView, RenderingContext, SEdge, SGraph, SGraphView, svg } from 'sprotty'; +import { renderAndGate, renderEllipse, renderHorizontalLine, renderInhibitGate, renderKnGate, renderOrGate, renderRectangle, renderRoundedRectangle, renderVerticalLine } from "../views-rendering"; +import { DescriptionNode, FTAEdge, FTAGraph, FTANode, FTAPort, FTA_DESCRIPTION_NODE_TYPE, FTA_EDGE_TYPE, FTA_NODE_TYPE, FTA_PORT_TYPE, FTNodeType } from './fta-model'; @injectable() export class PolylineArrowEdgeViewFTA extends PolylineEdgeView { @@ -32,12 +32,50 @@ export class PolylineArrowEdgeViewFTA extends PolylineEdgeView { const p = segments[i]; path += ` L ${p.x},${p.y}`; } + // renderings for all junction points + const junctionPointRenderings = edge.junctionPoints?.map(junctionPoint => + renderEllipse(junctionPoint.x, junctionPoint.y, 4, 4, 1) + ); + // if an FTANode is selected, the components not connected to it should fade out - return ; + return + + {...(junctionPointRenderings ?? [])} + ; } } +@injectable() +export class FTAInvisibleEdgeView extends PolylineArrowEdgeViewFTA { + render(edge: Readonly, context: RenderingContext, args?: IViewArgs | undefined): VNode | undefined { + return ; + } +} + +@injectable() +export class DescriptionNodeView extends RectangularNodeView { + render(node: DescriptionNode, context: RenderingContext): VNode | undefined { + // render the description node similar to an on edge label + const element = renderRectangle(node); + const border1 = renderHorizontalLine(node); + const border2 = renderHorizontalLine(node); + const edge = renderVerticalLine(node); + const translateBorder = `translate(0, ${Math.max(node.size.height, 0)})`; + const translateEdge = `translate(${Math.max(node.size.width / 2.0, 0)}, 0)`; + return + {edge} + {element} + {border1} + {border2} + {context.renderChildren(node)} + ; + } +} + @injectable() export class FTANodeView extends RectangularNodeView { @@ -45,14 +83,20 @@ export class FTANodeView extends RectangularNodeView { // create the element based on the type of the node let element: VNode; switch (node.nodeType) { + case FTNodeType.PARENT: + // parent is invisible + return + {context.renderChildren(node)} + ; case FTNodeType.TOPEVENT: element = renderRectangle(node); break; - case (FTNodeType.COMPONENT || FTNodeType.CONDITION): - element = renderOval(node); - break; + case FTNodeType.COMPONENT: case FTNodeType.CONDITION: - element = renderOval(node); + element = renderRoundedRectangle(node, 15, 15); break; case FTNodeType.AND: element = renderAndGate(node); @@ -76,7 +120,7 @@ export class FTANodeView extends RectangularNodeView { class-fta-node={true} class-mouseover={node.hoverFeedback} class-greyed-out={node.notConnectedToSelectedCutSet}> - {element} + {element} {context.renderChildren(node)} ; } @@ -85,24 +129,85 @@ export class FTANodeView extends RectangularNodeView { @injectable() export class FTAGraphView extends SGraphView { - render(model: Readonly, context: RenderingContext): VNode { + render(model: Readonly, context: RenderingContext): VNode { if (model.children.length !== 0) { - this.highlightConnectedToCutSet(model.children[0] as FTANode); + + // find top event + let topEvent: FTANode | undefined; + model.children.forEach((child) => { + if (child.type === FTA_NODE_TYPE) { + if ((child as FTANode).topOfAnalysis) { + topEvent = child as FTANode; + } else { + const children = child.children.filter((child) => child.type === FTA_NODE_TYPE); + const toaChild = children.find(child => (child as FTANode).topOfAnalysis); + if (toaChild) { + topEvent = toaChild as FTANode; + } + } + } + }); + + if (topEvent) { + // if a cut set is selected, for which the top event is not the root, + // we must hide the edges we dont inspect when calling "highlightConnectedToCutSet" + model.children.forEach(child => { + if (child.type === FTA_EDGE_TYPE) { + (child as FTAEdge).notConnectedToSelectedCutSet = true; + } else if (child.type === FTA_NODE_TYPE) { + (child as FTANode).children.forEach(child => { + if (child.type === FTA_EDGE_TYPE) { + (child as FTAEdge).notConnectedToSelectedCutSet = true; + } + }); + } + }); + // highlight connected nodes + if (topEvent.parent.children.find(child => child.type === FTA_DESCRIPTION_NODE_TYPE)) { + this.highlightConnectedToCutSet(model, topEvent.parent as FTANode); + } else { + this.highlightConnectedToCutSet(model, topEvent as FTANode); + } + } } return super.render(model, context); } - - protected highlightConnectedToCutSet(node: FTANode): void { - for (const edge of node.outgoingEdges) { - (edge as FTAEdge).notConnectedToSelectedCutSet = true; - const target = edge.target as FTANode; - this.highlightConnectedToCutSet(target); - if (!target.notConnectedToSelectedCutSet) { - node.notConnectedToSelectedCutSet = false; - (edge as FTAEdge).notConnectedToSelectedCutSet = false; + /** + * Highlights the nodes and edges connected to the selected cut set. + * @param model The FTAGraph. + * @param currentNode The current node, which should be handled including its targets. + */ + protected highlightConnectedToCutSet(model: SGraph, currentNode: FTANode): void { + for (const port of currentNode.children.filter(child => child.type === FTA_PORT_TYPE)) { + const edge = model.children.find(child => child.type === FTA_EDGE_TYPE && (child as FTAEdge).sourceId === port.id) as FTAEdge; + if (edge) { + edge.notConnectedToSelectedCutSet = true; + if (edge.target instanceof FTAPort) { + const target = (edge.target as FTAPort).parent as FTANode; + // handle successor nodes + this.highlightConnectedToCutSet(model, target); + // handle current node + if (!target.notConnectedToSelectedCutSet) { + currentNode.notConnectedToSelectedCutSet = false; + edge.notConnectedToSelectedCutSet = false; + } + // handle edges in parents + if (currentNode.nodeType === FTNodeType.PARENT) { + const innerEdge = currentNode.children.find(child => child.type === FTA_EDGE_TYPE && (child as FTAEdge).targetId === edge.sourceId) as FTAEdge; + innerEdge.notConnectedToSelectedCutSet = edge.notConnectedToSelectedCutSet; + } + } } } + // handle nodes in parents + if (currentNode.nodeType === FTNodeType.PARENT) { + currentNode.children.forEach(child => { + if (child.type === FTA_NODE_TYPE || child.type === FTA_DESCRIPTION_NODE_TYPE) { + (child as FTANode).notConnectedToSelectedCutSet = currentNode.notConnectedToSelectedCutSet; + } + }); + } } -} \ No newline at end of file +} diff --git a/extension/src-webview/views-rendering.tsx b/extension/src-webview/views-rendering.tsx index 48fea548..12003c89 100644 --- a/extension/src-webview/views-rendering.tsx +++ b/extension/src-webview/views-rendering.tsx @@ -32,6 +32,25 @@ export function renderOval(node: SNode): VNode { ry={Math.max(node.size.height, 0) / 2.0} />; } +/** + * Creates an ellipse for {@code node}. + * @param x The x-coordinate of the ellipse. + * @param y The y-coordinate of the ellipse. + * @param width The width of the ellipse. + * @param height The height of the ellipse. + * @param lineWidth The line width of the ellipse. + * @returns an ellipse for {@code node}. + */ +export function renderEllipse(x: number | undefined, y: number | undefined, width: number, height: number, lineWidth: number): VNode { + return ; +} + /** * Creates a rectangle for {@code node}. * @param node The node that should be represented by a rectangle. @@ -44,15 +63,39 @@ export function renderRectangle(node: SNode): VNode { />; } +/** + * Creates rectangle borders for {@code node} at the top and bottom. + * @param node The node that should be represented by a rectangle. + * @returns rectangle borders for {@code node} at the top and bottom. + */ +export function renderHorizontalLine(node: SNode): VNode { + return + + ; +} + +/** + * Creates a vertical line going through {@code node} in the middle. + * @param node The node for which the line should be created. + * @returns a vertical line for {@code node} going through its mid. + */ +export function renderVerticalLine(node: SNode): VNode { + return + + ; +} + /** * Creates a rounded rectangle for {@code node}. * @param node The node that should be represented by a rounded rectangle. + * @param rx The x-radius of the rounded corners. + * @param ry The y-radius of the rounded corners. * @returns A rounded rectangle for {@code node}. */ -export function renderRoundedRectangle(node: SNode): VNode { +export function renderRoundedRectangle(node: SNode, rx = 5, ry = 5): VNode { return ; } @@ -197,16 +240,7 @@ export function renderAndGate(node: SNode): VNode { * @returns An Or-Gate for {@code node}. */ export function renderOrGate(node: SNode): VNode { - const leftX = 0; - const rightX = Math.max(node.size.width, 0); - const midX = rightX / 2.0; - const botY = Math.max(node.size.height, 0); - const nearBotY = botY - 5; - const midY = Math.max(node.size.height, 0) / 2; - const topY = 0; - const path = `M${leftX},${midY} V ${botY}` + `C ${leftX}, ${botY} ${leftX+10}, ${nearBotY} ${midX}, ${nearBotY} ${rightX-10}, ${nearBotY} ${rightX}, ${botY} ${rightX}, ${botY}` - + `V ${midY} A ${node.size.width},${node.size.height-10},${0},${0},${0},${midX},${topY} A ${node.size.width},${node.size.height-10},${0},${0},${0},${leftX},${midY} Z`; - + const path = createOrGate(node); return ; @@ -218,16 +252,10 @@ export function renderOrGate(node: SNode): VNode { * @returns An Kn-Gate for {@code node}. */ export function renderKnGate(node: SNode, k: number, n: number): VNode { - const leftX = 0; const rightX = Math.max(node.size.width, 0); const midX = rightX / 2.0; const botY = Math.max(node.size.height, 0); - const nearBotY = Math.max(node.size.height, 0) - (Math.max(node.size.height, 0) / 10.0); - const midY = Math.max(node.size.height, 0) / 2; - const topY = 0; - const path = `M${leftX},${midY} V ${botY}` + `C ${leftX}, ${botY} ${leftX+10}, ${nearBotY} ${midX}, ${nearBotY} ${rightX-10}, ${nearBotY} ${rightX}, ${botY} ${rightX}, ${botY}` - + `V ${midY} A ${node.size.width},${node.size.height-10},${0},${0},${0},${midX},${topY} A ${node.size.width},${node.size.height-10},${0},${0},${0},${leftX},${midY} Z`; - + const path = createOrGate(node); return ( @@ -238,6 +266,25 @@ export function renderKnGate(node: SNode, k: number, n: number): VNode { ); } +/** + * Creates an Or-Gate for {@code node}. + * @param node The node that should be represented by an Or-Gate. + * @returns an Or-Gate for {@code node}. + */ +function createOrGate(node: SNode): string { + const leftX = 0; + const rightX = Math.max(node.size.width, 0); + const midX = rightX / 2.0; + const botY = Math.max(node.size.height, 0); + const nearBotY = botY - (Math.max(node.size.height, 0) / 10.0); + const midY = Math.max(node.size.height, 0) / 2; + const topY = 0; + const path = `M${leftX},${midY} V ${botY}` + `C ${leftX}, ${botY} ${leftX + 10}, ${nearBotY} ${midX}, ${nearBotY} ${rightX - 10}, ${nearBotY} ${rightX}, ${botY} ${rightX}, ${botY}` + + `V ${midY} A ${node.size.width},${node.size.height - 10},${0},${0},${0},${midX},${topY} A ${node.size.width},${node.size.height - 10},${0},${0},${0},${leftX},${midY} Z`; + + return path; +} + /** * Creates an Inhibit-Gate for {@code node}. * @param node The node that should be represented by an Inhibit-Gate. diff --git a/extension/src/actions.ts b/extension/src/actions.ts index 11710c6d..0b283813 100644 --- a/extension/src/actions.ts +++ b/extension/src/actions.ts @@ -59,4 +59,36 @@ export namespace GenerateSVGsAction { export function isThisAction(action: Action): action is GenerateSVGsAction { return action.kind === GenerateSVGsAction.KIND; } -} \ No newline at end of file +} + +/** Send from client to server to start a cut set analysis with the start node given by the startId */ +export interface CutSetAnalysisAction extends Action { + kind: typeof CutSetAnalysisAction.KIND; + startId: string +} +export namespace CutSetAnalysisAction { + export const KIND = 'cutSetAnalysis'; + + export function create(startId: string,): CutSetAnalysisAction { + return { + kind: KIND, + startId, + }; + } +} + +/** Send from client to server to start a minimal cut set analysis with the start node given by the startId */ +export interface MinimalCutSetAnalysisAction extends Action { + kind: typeof MinimalCutSetAnalysisAction.KIND; + startId: string +} +export namespace MinimalCutSetAnalysisAction { + export const KIND = 'minimalCutSetAnalysis'; + + export function create(startId: string,): MinimalCutSetAnalysisAction { + return { + kind: KIND, + startId, + }; + } +} diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 484ca26e..99be1184 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -15,19 +15,19 @@ * SPDX-License-Identifier: EPL-2.0 */ -import * as path from 'path'; -import { registerDefaultCommands } from 'sprotty-vscode'; -import { LspSprottyEditorProvider, LspSprottyViewProvider } from 'sprotty-vscode/lib/lsp'; -import * as vscode from 'vscode'; -import { LanguageClient, LanguageClientOptions, ServerOptions, TransportKind } from 'vscode-languageclient/node'; -import { Messenger } from 'vscode-messenger'; -import { command } from './constants'; -import { StpaLspVscodeExtension } from './language-extension'; -import { createSTPAResultMarkdownFile } from './report/md-export'; -import { StpaResult } from './report/utils'; -import { createSBMs } from './sbm/sbm-generation'; -import { LTLFormula } from './sbm/utils'; -import { createOutputChannel, createQuickPickForWorkspaceOptions } from './utils'; +import * as path from "path"; +import { registerDefaultCommands } from "sprotty-vscode"; +import { LspSprottyEditorProvider, LspSprottyViewProvider } from "sprotty-vscode/lib/lsp"; +import * as vscode from "vscode"; +import { LanguageClient, LanguageClientOptions, ServerOptions, TransportKind } from "vscode-languageclient/node"; +import { Messenger } from "vscode-messenger"; +import { command } from "./constants"; +import { StpaLspVscodeExtension } from "./language-extension"; +import { createSTPAResultMarkdownFile } from "./report/md-export"; +import { StpaResult } from "./report/utils"; +import { createSBMs } from "./sbm/sbm-generation"; +import { LTLFormula } from "./sbm/utils"; +import { createFile, createOutputChannel, createQuickPickForWorkspaceOptions } from "./utils"; let languageClient: LanguageClient; @@ -36,68 +36,71 @@ let languageClient: LanguageClient; * The file ending should also be the language id, since it is also used to * register document selectors in the language client. */ -const supportedFileEndings = ['stpa', 'fta']; +const supportedFileEndings = ["stpa", "fta"]; export function activate(context: vscode.ExtensionContext): void { - vscode.window.showInformationMessage('Activating STPA extension'); + vscode.window.showInformationMessage("Activating STPA extension"); - const diagramMode = process.env.DIAGRAM_MODE || 'panel'; - if (!['panel', 'editor', 'view'].includes(diagramMode)) { + const diagramMode = process.env.DIAGRAM_MODE || "panel"; + if (!["panel", "editor", "view"].includes(diagramMode)) { throw new Error("The environment variable 'DIAGRAM_MODE' must be set to 'panel', 'editor' or 'view'."); } languageClient = createLanguageClient(context); // Create context key of supported languages - vscode.commands.executeCommand('setContext', 'pasta.languages', supportedFileEndings); + vscode.commands.executeCommand("setContext", "pasta.languages", supportedFileEndings); - if (diagramMode === 'panel') { + if (diagramMode === "panel") { // Set up webview panel manager for freestyle webviews - const webviewPanelManager = new StpaLspVscodeExtension({ - extensionUri: context.extensionUri, - languageClient, - supportedFileExtensions: ['.stpa', '.fta'], - singleton: true, - messenger: new Messenger({ ignoreHiddenViews: false }) - }, 'pasta'); - registerDefaultCommands(webviewPanelManager, context, { extensionPrefix: 'pasta' }); + const webviewPanelManager = new StpaLspVscodeExtension( + { + extensionUri: context.extensionUri, + languageClient, + supportedFileExtensions: [".stpa", ".fta"], + singleton: true, + messenger: new Messenger({ ignoreHiddenViews: false }), + }, + "pasta" + ); + registerDefaultCommands(webviewPanelManager, context, { extensionPrefix: "pasta" }); registerTextEditorSync(webviewPanelManager, context); - registerSTPACommands(webviewPanelManager, context, { extensionPrefix: 'pasta' }); - registerFTACommands(webviewPanelManager, context, { extensionPrefix: 'pasta' }); + registerSTPACommands(webviewPanelManager, context, { extensionPrefix: "pasta" }); + registerFTACommands(webviewPanelManager, context, { extensionPrefix: "pasta" }); } - if (diagramMode === 'editor') { + if (diagramMode === "editor") { // Set up webview editor associated with file type const webviewEditorProvider = new LspSprottyEditorProvider({ extensionUri: context.extensionUri, - viewType: 'stpa', + viewType: "stpa", languageClient, - supportedFileExtensions: ['.stpa'] + supportedFileExtensions: [".stpa"], }); context.subscriptions.push( - vscode.window.registerCustomEditorProvider('stpa', webviewEditorProvider, { - webviewOptions: { retainContextWhenHidden: true } + vscode.window.registerCustomEditorProvider("stpa", webviewEditorProvider, { + webviewOptions: { retainContextWhenHidden: true }, }) ); - registerDefaultCommands(webviewEditorProvider, context, { extensionPrefix: 'stpa' }); + registerDefaultCommands(webviewEditorProvider, context, { extensionPrefix: "stpa" }); } - if (diagramMode === 'view') { + if (diagramMode === "view") { // Set up webview view shown in the side panel const webviewViewProvider = new LspSprottyViewProvider({ extensionUri: context.extensionUri, - viewType: 'stpa', + viewType: "stpa", languageClient, - supportedFileExtensions: ['.stpa'], + supportedFileExtensions: [".stpa"], openActiveEditor: true, - messenger: new Messenger({ ignoreHiddenViews: false }) + messenger: new Messenger({ ignoreHiddenViews: false }), }); context.subscriptions.push( - vscode.window.registerWebviewViewProvider('states', webviewViewProvider, { - webviewOptions: { retainContextWhenHidden: true } + vscode.window.registerWebviewViewProvider("states", webviewViewProvider, { + webviewOptions: { retainContextWhenHidden: true }, }) ); - registerDefaultCommands(webviewViewProvider, context, { extensionPrefix: 'stpa' }); + registerDefaultCommands(webviewViewProvider, context, { extensionPrefix: "stpa" }); } } @@ -107,7 +110,17 @@ export async function deactivate(): Promise { } } -function registerSTPACommands(manager: StpaLspVscodeExtension, context: vscode.ExtensionContext, options: { extensionPrefix: string; }): void { +/** + * Register all commands that are specific to STPA. + * @param manager The manager that handles the webview panels. + * @param context The context of the extension. + * @param options The options for the commands. + */ +function registerSTPACommands( + manager: StpaLspVscodeExtension, + context: vscode.ExtensionContext, + options: { extensionPrefix: string } +): void { context.subscriptions.push( vscode.commands.registerCommand( options.extensionPrefix + ".contextTable.open", @@ -184,6 +197,17 @@ function registerSTPACommands(manager: StpaLspVscodeExtension, context: vscode.E }) ); + // command for creating fault trees + context.subscriptions.push( + vscode.commands.registerCommand(options.extensionPrefix + ".stpa.ft.generation", async (uri: vscode.Uri) => { + await manager.lsReady; + const texts: string[] = await languageClient.sendRequest("generate/faultTrees", uri.toString()); + const baseUri = uri.toString().substring(0, uri.toString().lastIndexOf("/")); + const fileName = uri.toString().substring(uri.toString().lastIndexOf("/"), uri.toString().lastIndexOf(".")); + texts.forEach((text, index) => createFile(`${baseUri}/generatedFTA${fileName}-fta${index}.fta`, text)); + }) + ); + // register commands that other extensions can use context.subscriptions.push( vscode.commands.registerCommand(command.getLTLFormula, async (uri: string) => { @@ -198,65 +222,93 @@ function registerSTPACommands(manager: StpaLspVscodeExtension, context: vscode.E ); } -function registerFTACommands(manager: StpaLspVscodeExtension, context: vscode.ExtensionContext, options: { extensionPrefix: string; }): void { +/** + * Register all commands that are specific to FTA. + * @param manager The manager that handles the webview panels. + * @param context The context of the extension. + * @param options The options for the commands. + */ +function registerFTACommands( + manager: StpaLspVscodeExtension, + context: vscode.ExtensionContext, + options: { extensionPrefix: string } +): void { // commands for computing and displaying the (minimal) cut sets of the fault tree. context.subscriptions.push( - vscode.commands.registerCommand(options.extensionPrefix + ".fta.cutSets", async (uri: vscode.Uri) => { - const cutSets: string[] = await languageClient.sendRequest("generate/getCutSets", uri.path); - await manager.openDiagram(uri); - handleCutSets(manager, cutSets, false); - }) + vscode.commands.registerCommand( + options.extensionPrefix + ".fta.cutSets", + async (uri: vscode.Uri, startId?: string) => { + const cutSets: string[] = await languageClient.sendRequest("cutSets/generate", { + uri: uri.path, + startId, + }); + await manager.openDiagram(uri); + handleCutSets(cutSets, false); + } + ) ); context.subscriptions.push( - vscode.commands.registerCommand(options.extensionPrefix + ".fta.minimalCutSets", async (uri: vscode.Uri) => { - const minimalCutSets: string[] = await languageClient.sendRequest("generate/getMinimalCutSets", uri.path); - await manager.openDiagram(uri); - handleCutSets(manager, minimalCutSets, true); - }) + vscode.commands.registerCommand( + options.extensionPrefix + ".fta.minimalCutSets", + async (uri: vscode.Uri, startId?: string) => { + const minimalCutSets: string[] = await languageClient.sendRequest("cutSets/generateMinimal", { + uri: uri.path, + startId, + }); + await manager.openDiagram(uri); + handleCutSets(minimalCutSets, true); + } + ) ); } -function handleCutSets(manager: StpaLspVscodeExtension, cutSets: string[], minimal?: boolean): void { +/** + * Handles the result of the cut set analysis. + * @param manager The manager that handles the webview panels. + * @param cutSets The cut sets that should be handled. + * @param minimal Determines whether the cut sets are minimal or not. + */ +function handleCutSets(cutSets: string[], minimal?: boolean): void { // print cut sets to output channel createOutputChannel(cutSets, "FTA Cut Sets", minimal); } function createLanguageClient(context: vscode.ExtensionContext): LanguageClient { - const serverModule = context.asAbsolutePath(path.join('pack', 'language-server')); + const serverModule = context.asAbsolutePath(path.join("pack", "language-server")); // The debug options for the server // --inspect=6009: runs the server in Node's Inspector mode so VS Code can attach to the server for debugging. // By setting `process.env.DEBUG_BREAK` to a truthy value, the language server will wait until a debugger is attached. - const debugOptions = { execArgv: ['--nolazy', `--inspect${process.env.DEBUG_BREAK ? '-brk' : ''}=${process.env.DEBUG_SOCKET || '6009'}`] }; + const debugOptions = { + execArgv: [ + "--nolazy", + `--inspect${process.env.DEBUG_BREAK ? "-brk" : ""}=${process.env.DEBUG_SOCKET || "6009"}`, + ], + }; // If the extension is launched in debug mode then the debug server options are used // Otherwise the run options are used const serverOptions: ServerOptions = { run: { module: serverModule, transport: TransportKind.ipc }, - debug: { module: serverModule, transport: TransportKind.ipc, options: debugOptions } + debug: { module: serverModule, transport: TransportKind.ipc, options: debugOptions }, }; - const fileSystemWatcher = vscode.workspace.createFileSystemWatcher('**/*.{stpa,fta}'); + const fileSystemWatcher = vscode.workspace.createFileSystemWatcher("**/*.{stpa,fta}"); context.subscriptions.push(fileSystemWatcher); // Options to control the language client const clientOptions: LanguageClientOptions = { documentSelector: supportedFileEndings.map((ending) => ({ - scheme: 'file', + scheme: "file", language: ending, })), synchronize: { // Notify the server about file changes to files contained in the workspace - fileEvents: fileSystemWatcher - } + fileEvents: fileSystemWatcher, + }, }; // Create the language client and start the client. - const languageClient = new LanguageClient( - 'stpa', - 'stpa', - serverOptions, - clientOptions - ); + const languageClient = new LanguageClient("stpa", "stpa", serverOptions, clientOptions); // Start the client. This will also launch the server languageClient.start(); @@ -265,20 +317,21 @@ function createLanguageClient(context: vscode.ExtensionContext): LanguageClient function registerTextEditorSync(manager: StpaLspVscodeExtension, context: vscode.ExtensionContext): void { context.subscriptions.push( - vscode.workspace.onDidSaveTextDocument(async document => { + vscode.workspace.onDidSaveTextDocument(async (document) => { if (document) { manager.openDiagram(document.uri); } }) ); context.subscriptions.push( - vscode.workspace.onDidSaveTextDocument(async document => { + vscode.workspace.onDidSaveTextDocument(async (document) => { if (document) { + await languageClient.sendRequest("cutSets/reset"); manager.openDiagram(document.uri); if (manager.contextTable) { - languageClient.sendNotification('contextTable/getData', document.uri.toString()); + languageClient.sendNotification("contextTable/getData", document.uri.toString()); } } }) ); -} \ No newline at end of file +} diff --git a/extension/src/wview.ts b/extension/src/wview.ts index 57b54b22..9a54064a 100644 --- a/extension/src/wview.ts +++ b/extension/src/wview.ts @@ -3,7 +3,7 @@ * * http://rtsys.informatik.uni-kiel.de/kieler * - * Copyright 2022 by + * Copyright 2022-2023 by * + Kiel University * + Department of Computer Science * + Real-Time and Embedded Systems Group @@ -18,7 +18,7 @@ import { isActionMessage, SelectAction } from "sprotty-protocol"; import { LspWebviewEndpoint } from "sprotty-vscode/lib/lsp"; import * as vscode from "vscode"; -import { SendConfigAction } from "./actions"; +import { CutSetAnalysisAction, MinimalCutSetAnalysisAction, SendConfigAction } from "./actions"; export class StpaLspWebview extends LspWebviewEndpoint { receiveAction(message: any): Promise { @@ -32,6 +32,12 @@ export class StpaLspWebview extends LspWebviewEndpoint { case SelectAction.KIND: this.handleSelectAction(message.action as SelectAction); break; + case CutSetAnalysisAction.KIND: + this.handleCutSetAnalysisAction(message.action as CutSetAnalysisAction); + break; + case MinimalCutSetAnalysisAction.KIND: + this.handleMinimalCutSetAnalysisAction(message.action as MinimalCutSetAnalysisAction); + break; } } return super.receiveAction(message); @@ -42,13 +48,9 @@ export class StpaLspWebview extends LspWebviewEndpoint { * @param action The SelectAction. */ protected handleSelectAction(action: SelectAction): void { - // the uri string must be desrialized first, since sprotty serializes it and langium does not - if (this.diagramIdentifier) { - let uriString = this.diagramIdentifier.uri.toString(); - const match = uriString.match(/file:\/\/\/([a-z]):/i); - if (match) { - uriString = "file:///" + match[1] + "%3A" + uriString.substring(match[0].length); - } + // the uri string must be deserialized first, since sprotty serializes it and langium does not + const uriString = this.deserializeUriOfDiagramIdentifier(); + if (uriString !== "") { // send ID of the first selected element to the language server to highlight the textual definition in the editor this.languageClient.sendNotification("diagram/selected", { label: action.selectedElementsIDs[0], @@ -69,8 +71,52 @@ export class StpaLspWebview extends LspWebviewEndpoint { this.sendAction({ kind: SendConfigAction.KIND, options: renderOptions } as SendConfigAction); } + /** + * Updates the configuration of the PASTA extension. + * @param action The action containing the configuration options. + */ protected updateConfigValues(action: SendConfigAction): void { const configOptions = vscode.workspace.getConfiguration("pasta"); action.options.forEach((element) => configOptions.update(element.id, element.value)); } + + /** + * Executes the cut set analysis for the given start ID. + * @param action The action containing the start ID. + */ + protected handleCutSetAnalysisAction(action: CutSetAnalysisAction): void { + const uriString = this.deserializeUriOfDiagramIdentifier(); + if (uriString !== "") { + const uri = vscode.Uri.parse(uriString); + vscode.commands.executeCommand("pasta.fta.cutSets", uri, action.startId); + } + } + + /** + * Executes the minimal cut set analysis for the given start ID. + * @param action The action containing the start ID. + */ + protected handleMinimalCutSetAnalysisAction(action: MinimalCutSetAnalysisAction): void { + const uriString = this.deserializeUriOfDiagramIdentifier(); + if (uriString !== "") { + const uri = vscode.Uri.parse(uriString); + vscode.commands.executeCommand("pasta.fta.minimalCutSets", uri, action.startId); + } + } + + /** + * Deserializes the URI of the diagram identifier. + * @returns the deserialized URI of the diagram identifier. + */ + protected deserializeUriOfDiagramIdentifier(): string { + if (this.diagramIdentifier) { + let uriString = this.diagramIdentifier.uri.toString(); + const match = uriString.match(/file:\/\/\/([a-z]):/i); + if (match) { + uriString = "file:///" + match[1] + "%3A" + uriString.substring(match[0].length); + } + return uriString; + } + return ""; + } } diff --git a/yarn.lock b/yarn.lock index 0c932d95..5f0fa116 100644 --- a/yarn.lock +++ b/yarn.lock @@ -238,10 +238,10 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" -"@kieler/table-webview@^0.0.4": - version "0.0.4" - resolved "https://registry.yarnpkg.com/@kieler/table-webview/-/table-webview-0.0.4.tgz#c68a0652423a3c5f74a635fc5432e1430f758ee3" - integrity sha512-ZUAdX8dUCq72UdpFJz61bPX8eoJqnRuiJBOIFgOb+NqUke13zo4QmkeapmgA4zSKFBx5GC6nhSDQvVr9CMD4AQ== +"@kieler/table-webview@^0.0.3": + version "0.0.3" + resolved "https://registry.yarnpkg.com/@kieler/table-webview/-/table-webview-0.0.3.tgz#33199d9b0d8cd88d0aad6d4230617b94942482aa" + integrity sha512-XiDfn/MwHzVEpXLWC5DT6Ysg/5Zke3GlbtjBDDPRD1mLFXIekOCxkGYAKu068djqSAg3hsoiIhwLwWBfm48VNQ== dependencies: "@types/vscode" "^1.56.0" reflect-metadata "^0.1.13"