From a49083f31679c47e1685e0cedbc9a40cc8f48fd8 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Sat, 26 Aug 2023 21:44:58 +0200 Subject: refactor(frontent): improve graph drawing --- .../patches/d3-graphviz-npm-5.1.0-ba6bed3fec.patch | 13 + gradle/libs.versions.toml | 1 + subprojects/frontend/index.html | 1 + subprojects/frontend/src/editor/EditorStore.ts | 7 +- subprojects/frontend/src/editor/EditorTheme.ts | 4 +- .../frontend/src/graph/DotGraphVisualizer.tsx | 86 ++---- subprojects/frontend/src/graph/GraphStore.ts | 51 ++++ subprojects/frontend/src/graph/GraphTheme.tsx | 76 ++++- subprojects/frontend/src/graph/ZoomCanvas.tsx | 5 +- subprojects/frontend/src/graph/dotSource.ts | 309 +++++++++++++++++++++ subprojects/frontend/src/graph/postProcessSVG.ts | 133 ++++++++- subprojects/frontend/src/utils/svgURL.ts | 9 + .../frontend/src/xtext/xtextServiceResults.ts | 30 +- subprojects/frontend/vite.config.ts | 2 +- .../ProblemCrossrefProposalProvider.java | 5 +- .../ProblemSemanticHighlightingCalculator.java | 2 +- .../language/semantics/metadata/BuiltInDetail.java | 10 + .../language/semantics/metadata/ClassDetail.java | 16 ++ .../language/semantics/metadata/Metadata.java | 2 +- .../semantics/metadata/MetadataCreator.java | 181 ++++++++++++ .../language/semantics/metadata/NodeKind.java | 2 +- .../language/semantics/metadata/NodeMetadata.java | 2 +- .../metadata/OppositeReferenceDetail.java | 9 + .../semantics/metadata/PredicateDetail.java | 16 ++ .../semantics/metadata/ReferenceDetail.java | 16 ++ .../semantics/metadata/RelationDetail.java | 10 + .../language/semantics/metadata/RelationKind.java | 18 -- .../semantics/metadata/RelationMetadata.java | 3 +- .../language/semantics/model/ModelInitializer.java | 10 +- subprojects/language-web/build.gradle.kts | 1 + .../language/web/ProblemWebSocketServlet.java | 4 +- .../language/web/semantics/SemanticsService.java | 2 +- .../web/semantics/SemanticsSuccessResult.java | 5 +- .../language/web/semantics/SemanticsWorker.java | 24 +- .../web/xtext/server/message/XtextWebRequest.java | 9 +- .../xtext/servlet/RuntimeTypeAdapterFactory.java | 304 ++++++++++++++++++++ .../language/web/xtext/servlet/XtextWebSocket.java | 12 +- .../tools/refinery/language/utils/ProblemUtil.java | 5 +- yarn.lock | 4 +- 39 files changed, 1252 insertions(+), 147 deletions(-) create mode 100644 subprojects/frontend/src/graph/GraphStore.ts create mode 100644 subprojects/frontend/src/graph/dotSource.ts create mode 100644 subprojects/frontend/src/utils/svgURL.ts create mode 100644 subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/BuiltInDetail.java create mode 100644 subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/ClassDetail.java create mode 100644 subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/MetadataCreator.java create mode 100644 subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/OppositeReferenceDetail.java create mode 100644 subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/PredicateDetail.java create mode 100644 subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/ReferenceDetail.java create mode 100644 subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/RelationDetail.java delete mode 100644 subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/RelationKind.java create mode 100644 subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/RuntimeTypeAdapterFactory.java diff --git a/.yarn/patches/d3-graphviz-npm-5.1.0-ba6bed3fec.patch b/.yarn/patches/d3-graphviz-npm-5.1.0-ba6bed3fec.patch index 161db0d7..0a4110c5 100644 --- a/.yarn/patches/d3-graphviz-npm-5.1.0-ba6bed3fec.patch +++ b/.yarn/patches/d3-graphviz-npm-5.1.0-ba6bed3fec.patch @@ -49,6 +49,19 @@ index 96ae02b6edd947ac9086f3108986c08d91470cba..c4422b08d73f7fe73dc52ad905cf981d var data = extractAllElementsData(newSvg); this._dispatch.call('dataExtractEnd', this); postProcessDataPass1Local(data); +diff --git a/src/element.js b/src/element.js +index 5aa398a6cf2550e15f642aea4eaa5a1c69af69ad..5d799e38566e8f847aa1ba80f4c575911e9851cf 100644 +--- a/src/element.js ++++ b/src/element.js +@@ -108,6 +108,8 @@ export function createElement(data) { + return document.createTextNode(""); + } else if (data.tag == '#comment') { + return document.createComment(data.comment); ++ } else if (data.tag == 'div' || data.tag == 'DIV') { ++ return document.createElement('div'); + } else { + return document.createElementNS('http://www.w3.org/2000/svg', data.tag); + } diff --git a/src/graphviz.js b/src/graphviz.js index c4638cb0e4042844c59c52dfe4749e13999fef6e..28dcfb71ad787c78645c460a29e9c52295c5f6bf 100644 --- a/src/graphviz.js diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 637e68c6..45d3b35f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,7 @@ ecore-codegen = { group = "org.eclipse.emf", name = "org.eclipse.emf.codegen.eco gradlePlugin-frontend = { group = "org.siouan", name = "frontend-gradle-plugin-jdk11", version = "6.0.0" } gradlePlugin-shadow = { group = "com.github.johnrengelman", name = "shadow", version = "8.1.1" } gradlePlugin-sonarqube = { group = "org.sonarsource.scanner.gradle", name = "sonarqube-gradle-plugin", version = "4.3.0.3225" } +gson = { group = "com.google.code.gson", name = "gson", version = "2.10.1" } hamcrest = { group = "org.hamcrest", name = "hamcrest", version = "2.2" } jetty-server = { group = "org.eclipse.jetty", name = "jetty-server", version.ref = "jetty" } jetty-servlet = { group = "org.eclipse.jetty.ee10", name = "jetty-ee10-servlet", version.ref = "jetty" } diff --git a/subprojects/frontend/index.html b/subprojects/frontend/index.html index f4b46da2..8992d538 100644 --- a/subprojects/frontend/index.html +++ b/subprojects/frontend/index.html @@ -19,6 +19,7 @@ diff --git a/subprojects/frontend/src/editor/EditorStore.ts b/subprojects/frontend/src/editor/EditorStore.ts index 10f01099..b5989ad1 100644 --- a/subprojects/frontend/src/editor/EditorStore.ts +++ b/subprojects/frontend/src/editor/EditorStore.ts @@ -26,6 +26,7 @@ import { makeAutoObservable, observable, runInAction } from 'mobx'; import { nanoid } from 'nanoid'; import type PWAStore from '../PWAStore'; +import GraphStore from '../graph/GraphStore'; import getLogger from '../utils/getLogger'; import type XtextClient from '../xtext/XtextClient'; import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; @@ -66,7 +67,7 @@ export default class EditorStore { semanticsError: string | undefined; - semantics: SemanticsSuccessResult | undefined; + graph: GraphStore; constructor(initialValue: string, pwaStore: PWAStore) { this.id = nanoid(); @@ -86,12 +87,12 @@ export default class EditorStore { })().catch((error) => { log.error('Failed to load XtextClient', error); }); + this.graph = new GraphStore(); makeAutoObservable(this, { id: false, state: observable.ref, client: observable.ref, view: observable.ref, - semantics: observable.ref, searchPanel: false, lintPanel: false, contentAssist: false, @@ -298,7 +299,7 @@ export default class EditorStore { setSemantics(semantics: SemanticsSuccessResult) { this.semanticsError = undefined; - this.semantics = semantics; + this.graph.setSemantics(semantics); } dispose(): void { diff --git a/subprojects/frontend/src/editor/EditorTheme.ts b/subprojects/frontend/src/editor/EditorTheme.ts index 4508273b..308d5be0 100644 --- a/subprojects/frontend/src/editor/EditorTheme.ts +++ b/subprojects/frontend/src/editor/EditorTheme.ts @@ -10,9 +10,7 @@ import infoSVG from '@material-icons/svg/svg/info/baseline.svg?raw'; import warningSVG from '@material-icons/svg/svg/warning/baseline.svg?raw'; import { alpha, styled, type CSSObject } from '@mui/material/styles'; -function svgURL(svg: string): string { - return `url('data:image/svg+xml;utf8,${svg}')`; -} +import svgURL from '../utils/svgURL'; export default styled('div', { name: 'EditorTheme', diff --git a/subprojects/frontend/src/graph/DotGraphVisualizer.tsx b/subprojects/frontend/src/graph/DotGraphVisualizer.tsx index 29e750f5..291314ec 100644 --- a/subprojects/frontend/src/graph/DotGraphVisualizer.tsx +++ b/subprojects/frontend/src/graph/DotGraphVisualizer.tsx @@ -8,76 +8,24 @@ import * as d3 from 'd3'; import { type Graphviz, graphviz } from 'd3-graphviz'; import type { BaseType, Selection } from 'd3-selection'; import { reaction, type IReactionDisposer } from 'mobx'; +import { observer } from 'mobx-react-lite'; import { useCallback, useRef } from 'react'; import { useRootStore } from '../RootStoreProvider'; -import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; +import getLogger from '../utils/getLogger'; import GraphTheme from './GraphTheme'; import { FitZoomCallback } from './ZoomCanvas'; +import dotSource from './dotSource'; import postProcessSvg from './postProcessSVG'; -function toGraphviz( - semantics: SemanticsSuccessResult | undefined, -): string | undefined { - if (semantics === undefined) { - return undefined; - } - const lines = [ - 'digraph {', - 'graph [bgcolor=transparent];', - `node [fontsize=12, shape=plain, fontname="OpenSans"];`, - 'edge [fontsize=10.5, color=black, fontname="OpenSans"];', - ]; - const nodeIds = semantics.nodes.map((name, i) => name ?? `n${i}`); - lines.push( - ...nodeIds.map( - (id, i) => - `n${i} [id="${id}", label=<
${id}
node
>];`, - ), - ); - Object.keys(semantics.partialInterpretation).forEach((relation) => { - if (relation === 'builtin::equals' || relation === 'builtin::contains') { - return; - } - const tuples = semantics.partialInterpretation[relation]; - if (tuples === undefined) { - return; - } - const first = tuples[0]; - if (first === undefined || first.length !== 3) { - return; - } - const nameFragments = relation.split('::'); - const simpleName = nameFragments[nameFragments.length - 1] ?? relation; - lines.push( - ...tuples.map(([from, to, value]) => { - if ( - typeof from !== 'number' || - typeof to !== 'number' || - typeof value !== 'string' - ) { - return ''; - } - const isUnknown = value === 'UNKNOWN'; - return `n${from} -> n${to} [ - id="${nodeIds[from]},${nodeIds[to]},${relation}", - xlabel="${simpleName}", - style="${isUnknown ? 'dashed' : 'solid'}", - class="edge-${value}" - ];`; - }), - ); - }); - lines.push('}'); - return lines.join('\n'); -} +const LOG = getLogger('graph.DotGraphVisualizer'); function ptToPx(pt: number): number { return (pt * 4) / 3; } -export default function DotGraphVisualizer({ +function DotGraphVisualizer({ fitZoom, transitionTime, }: { @@ -88,6 +36,7 @@ export default function DotGraphVisualizer({ transitionTime ?? DotGraphVisualizer.defaultProps.transitionTime; const { editorStore } = useRootStore(); + const graph = editorStore?.graph; const disposerRef = useRef(); const graphvizRef = useRef< Graphviz | undefined @@ -113,6 +62,9 @@ export default function DotGraphVisualizer({ undefined >; renderer.keyMode('id'); + ['TRUE', 'UNKNOWN', 'ERROR'].forEach((icon) => + renderer.addImage(`#${icon}`, 16, 16), + ); renderer.zoom(false); renderer.tweenPrecision('5%'); renderer.tweenShapes(false); @@ -125,6 +77,7 @@ export default function DotGraphVisualizer({ */ renderer.transition(transition as any); let newViewBox = { width: 0, height: 0 }; + renderer.onerror(LOG.error.bind(LOG)); renderer.on( 'postProcessSVG', // @ts-expect-error Custom `d3-graphviz` hook not covered by typings. @@ -139,19 +92,24 @@ export default function DotGraphVisualizer({ height: ptToPx(svg.viewBox.baseVal.height), }; } else { + // Do not trigger fit zoom. newViewBox = { width: 0, height: 0 }; } }, ); + renderer.on('renderEnd', () => { + // `d3-graphviz` uses `` elements for traceability, + // so we only remove them after the rendering is finished. + d3.select(element).selectAll('title').remove(); + }); if (fitZoom !== undefined) { renderer.on('transitionStart', () => fitZoom(newViewBox)); } disposerRef.current = reaction( - () => editorStore?.semantics, - (semantics) => { - const str = toGraphviz(semantics); - if (str !== undefined) { - renderer.renderDot(str); + () => dotSource(graph), + (source) => { + if (source !== undefined) { + renderer.renderDot(source); } }, { fireImmediately: true }, @@ -159,7 +117,7 @@ export default function DotGraphVisualizer({ graphvizRef.current = renderer; } }, - [editorStore, fitZoom, transitionTimeOrDefault], + [graph, fitZoom, transitionTimeOrDefault], ); return <GraphTheme ref={setElement} />; @@ -169,3 +127,5 @@ DotGraphVisualizer.defaultProps = { fitZoom: undefined, transitionTime: 250, }; + +export default observer(DotGraphVisualizer); diff --git a/subprojects/frontend/src/graph/GraphStore.ts b/subprojects/frontend/src/graph/GraphStore.ts new file mode 100644 index 00000000..b59bfb7d --- /dev/null +++ b/subprojects/frontend/src/graph/GraphStore.ts @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import { makeAutoObservable, observable } from 'mobx'; + +import type { SemanticsSuccessResult } from '../xtext/xtextServiceResults'; + +export type Visibility = 'all' | 'must' | 'none'; + +export default class GraphStore { + semantics: SemanticsSuccessResult = { + nodes: [], + relations: [], + partialInterpretation: {}, + }; + + visibility = new Map<string, Visibility>(); + + constructor() { + makeAutoObservable(this, { + semantics: observable.ref, + }); + } + + getVisiblity(relation: string): Visibility { + return this.visibility.get(relation) ?? 'none'; + } + + setSemantics(semantics: SemanticsSuccessResult) { + this.semantics = semantics; + this.visibility.clear(); + const names = new Set<string>(); + this.semantics.relations.forEach(({ name, detail }) => { + names.add(name); + if (!this.visibility.has(name)) { + const newVisibility = detail.type === 'builtin' ? 'none' : 'all'; + this.visibility.set(name, newVisibility); + } + }); + const oldNames = new Set<string>(); + this.visibility.forEach((_, key) => oldNames.add(key)); + oldNames.forEach((key) => { + if (!names.has(key)) { + this.visibility.delete(key); + } + }); + } +} diff --git a/subprojects/frontend/src/graph/GraphTheme.tsx b/subprojects/frontend/src/graph/GraphTheme.tsx index 41ba6ba5..989bd0c2 100644 --- a/subprojects/frontend/src/graph/GraphTheme.tsx +++ b/subprojects/frontend/src/graph/GraphTheme.tsx @@ -4,19 +4,28 @@ * SPDX-License-Identifier: EPL-2.0 */ -import { styled, type CSSObject } from '@mui/material/styles'; +import cancelSVG from '@material-icons/svg/svg/cancel/baseline.svg?raw'; +import labelSVG from '@material-icons/svg/svg/label/baseline.svg?raw'; +import labelOutlinedSVG from '@material-icons/svg/svg/label/outline.svg?raw'; +import { alpha, styled, type CSSObject } from '@mui/material/styles'; -function createEdgeColor(suffix: string, color: string): CSSObject { +import svgURL from '../utils/svgURL'; + +function createEdgeColor( + suffix: string, + stroke: string, + fill?: string, +): CSSObject { return { - [`& .edge-${suffix}`]: { + [`.edge-${suffix}`]: { '& text': { - fill: color, + fill: stroke, }, '& [stroke="black"]': { - stroke: color, + stroke, }, '& [fill="black"]': { - fill: color, + fill: fill ?? stroke, }, }, }; @@ -27,7 +36,7 @@ export default styled('div', { })(({ theme }) => ({ '& svg': { userSelect: 'none', - '& .node': { + '.node': { '& text': { fontFamily: theme.typography.fontFamily, fill: theme.palette.text.primary, @@ -43,10 +52,32 @@ export default styled('div', { }, '& [fill="white"]': { fill: theme.palette.background.default, - stroke: theme.palette.background.default, }, }, - '& .edge': { + '.node-INDIVIDUAL': { + '& [stroke="black"]': { + strokeWidth: 2, + }, + }, + '.node-shadow[fill="white"]': { + fill: alpha( + theme.palette.text.primary, + theme.palette.mode === 'dark' ? 0.32 : 0.24, + ), + }, + '.node-exists-UNKNOWN [stroke="black"]': { + strokeDasharray: '5 2', + }, + '.node-exists-FALSE': { + '& [fill="green"]': { + fill: theme.palette.background.default, + }, + '& [stroke="black"]': { + strokeDasharray: '1 3', + stroke: theme.palette.text.secondary, + }, + }, + '.edge': { '& text': { fontFamily: theme.typography.fontFamily, fill: theme.palette.text.primary, @@ -58,7 +89,32 @@ export default styled('div', { fill: theme.palette.text.primary, }, }, - ...createEdgeColor('UNKNOWN', theme.palette.text.secondary), + ...createEdgeColor('UNKNOWN', theme.palette.text.secondary, 'none'), ...createEdgeColor('ERROR', theme.palette.error.main), + '.icon': { + maskSize: '12px 12px', + maskPosition: '50% 50%', + maskRepeat: 'no-repeat', + width: '100%', + height: '100%', + }, + '.icon-TRUE': { + maskImage: svgURL(labelSVG), + background: theme.palette.text.primary, + }, + '.icon-UNKNOWN': { + maskImage: svgURL(labelOutlinedSVG), + background: theme.palette.text.secondary, + }, + '.icon-ERROR': { + maskImage: svgURL(cancelSVG), + background: theme.palette.error.main, + }, + 'text.label-UNKNOWN': { + fill: theme.palette.text.secondary, + }, + 'text.label-ERROR': { + fill: theme.palette.error.main, + }, }, })); diff --git a/subprojects/frontend/src/graph/ZoomCanvas.tsx b/subprojects/frontend/src/graph/ZoomCanvas.tsx index b8faae27..2bb7f139 100644 --- a/subprojects/frontend/src/graph/ZoomCanvas.tsx +++ b/subprojects/frontend/src/graph/ZoomCanvas.tsx @@ -148,7 +148,8 @@ export default function ZoomCanvas({ const [x, y] = d3.pointer(event, canvas); return [x - width / 2, y - height / 2]; }) - .centroid([0, 0]); + .centroid([0, 0]) + .scaleExtent([1 / 32, 8]); zoomBehavior.on( 'zoom', (event: d3.D3ZoomEvent<HTMLDivElement, unknown>) => { @@ -214,6 +215,6 @@ export default function ZoomCanvas({ ZoomCanvas.defaultProps = { children: undefined, - fitPadding: 16, + fitPadding: 8, transitionTime: 250, }; diff --git a/subprojects/frontend/src/graph/dotSource.ts b/subprojects/frontend/src/graph/dotSource.ts new file mode 100644 index 00000000..bf45d303 --- /dev/null +++ b/subprojects/frontend/src/graph/dotSource.ts @@ -0,0 +1,309 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import type { + NodeMetadata, + RelationMetadata, +} from '../xtext/xtextServiceResults'; + +import type GraphStore from './GraphStore'; + +const EDGE_WEIGHT = 1; +const CONTAINMENT_WEIGHT = 5; +const UNKNOWN_WEIGHT_FACTOR = 0.5; + +function nodeName({ simpleName, kind }: NodeMetadata): string { + switch (kind) { + case 'INDIVIDUAL': + return `<b>${simpleName}</b>`; + case 'NEW': + return `<i>${simpleName}</i>`; + default: + return simpleName; + } +} + +function relationName({ simpleName, detail }: RelationMetadata): string { + if (detail.type === 'class' && detail.abstractClass) { + return `<i>${simpleName}</i>`; + } + if (detail.type === 'reference' && detail.containment) { + return `<b>${simpleName}</b>`; + } + return simpleName; +} + +interface NodeData { + exists: string; + equalsSelf: string; + unaryPredicates: Map<RelationMetadata, string>; +} + +function computeNodeData(graph: GraphStore): NodeData[] { + const { + semantics: { nodes, relations, partialInterpretation }, + } = graph; + + const nodeData = Array.from(Array(nodes.length)).map(() => ({ + exists: 'FALSE', + equalsSelf: 'FALSE', + unaryPredicates: new Map(), + })); + + relations.forEach((relation) => { + if (relation.arity !== 1) { + return; + } + const visibility = graph.getVisiblity(relation.name); + if (visibility === 'none') { + return; + } + const interpretation = partialInterpretation[relation.name] ?? []; + interpretation.forEach(([index, value]) => { + if ( + typeof index === 'number' && + typeof value === 'string' && + (visibility === 'all' || value !== 'UNKNOWN') + ) { + nodeData[index]?.unaryPredicates?.set(relation, value); + } + }); + }); + + partialInterpretation['builtin::exists']?.forEach(([index, value]) => { + if (typeof index === 'number' && typeof value === 'string') { + const data = nodeData[index]; + if (data !== undefined) { + data.exists = value; + } + } + }); + + partialInterpretation['builtin::equals']?.forEach(([index, other, value]) => { + if ( + typeof index === 'number' && + index === other && + typeof value === 'string' + ) { + const data = nodeData[index]; + if (data !== undefined) { + data.equalsSelf = value; + } + } + }); + + return nodeData; +} + +function createNodes(graph: GraphStore, lines: string[]): void { + const nodeData = computeNodeData(graph); + const { + semantics: { nodes }, + } = graph; + + nodes.forEach((node, i) => { + const data = nodeData[i]; + if (data === undefined) { + return; + } + const classes = [ + `node-${node.kind} node-exists-${data.exists} node-equalsSelf-${data.equalsSelf}`, + ].join(' '); + const name = nodeName(node); + const border = node.kind === 'INDIVIDUAL' ? 2 : 1; + lines.push(`n${i} [id="${node.name}", class="${classes}", label=< + <table border="${border}" cellborder="0" cellspacing="0" style="rounded" bgcolor="white"> + <tr><td cellpadding="4.5" width="32" bgcolor="green">${name}</td></tr>`); + if (data.unaryPredicates.size > 0) { + lines.push( + '<hr/><tr><td cellpadding="4.5"><table fixedsize="TRUE" align="left" border="0" cellborder="0" cellspacing="0" cellpadding="1.5">', + ); + data.unaryPredicates.forEach((value, relation) => { + lines.push( + `<tr> + <td><img src="#${value}"/></td> + <td width="1.5"></td> + <td align="left" href="#${value}" id="${node.name},${ + relation.name + },label">${relationName(relation)}</td> + </tr>`, + ); + }); + lines.push('</table></td></tr>'); + } + lines.push('</table>>]'); + }); +} + +function compare( + a: readonly (number | string)[], + b: readonly number[], +): number { + if (a.length !== b.length + 1) { + throw new Error('Tuple length mismatch'); + } + for (let i = 0; i < b.length; i += 1) { + const aItem = a[i]; + const bItem = b[i]; + if (typeof aItem !== 'number' || typeof bItem !== 'number') { + throw new Error('Invalid tuple'); + } + if (aItem < bItem) { + return -1; + } + if (aItem > bItem) { + return 1; + } + } + return 0; +} + +function binarySerach( + tuples: readonly (readonly (number | string)[])[], + key: readonly number[], +): string | undefined { + let lower = 0; + let upper = tuples.length - 1; + while (lower <= upper) { + const middle = Math.floor((lower + upper) / 2); + const tuple = tuples[middle]; + if (tuple === undefined) { + throw new Error('Range error'); + } + const result = compare(tuple, key); + if (result === 0) { + const found = tuple[key.length]; + if (typeof found !== 'string') { + throw new Error('Invalid tuple value'); + } + return found; + } + if (result < 0) { + lower = middle + 1; + } else { + // result > 0 + upper = middle - 1; + } + } + return undefined; +} + +function createRelationEdges( + graph: GraphStore, + relation: RelationMetadata, + showUnknown: boolean, + lines: string[], +): void { + const { + semantics: { nodes, partialInterpretation }, + } = graph; + const { detail } = relation; + + let constraint: 'true' | 'false' = 'true'; + let weight = EDGE_WEIGHT; + let penwidth = 1; + let label = `"${relation.simpleName}"`; + if (detail.type === 'reference' && detail.containment) { + weight = CONTAINMENT_WEIGHT; + label = `<<b>${relation.simpleName}</b>>`; + penwidth = 2; + } else if ( + detail.type === 'opposite' && + graph.getVisiblity(detail.opposite) !== 'none' + ) { + constraint = 'false'; + weight = 0; + } + + const tuples = partialInterpretation[relation.name] ?? []; + tuples.forEach(([from, to, value]) => { + const isUnknown = value === 'UNKNOWN'; + if ( + (!showUnknown && isUnknown) || + typeof from !== 'number' || + typeof to !== 'number' || + typeof value !== 'string' + ) { + return; + } + + const fromNode = nodes[from]; + const toNode = nodes[to]; + if (fromNode === undefined || toNode === undefined) { + return; + } + + let dir = 'forward'; + let edgeConstraint = constraint; + let edgeWeight = weight; + const opposite = binarySerach(tuples, [to, from]); + const oppositeUnknown = opposite === 'UNKNOWN'; + const oppositeSet = opposite !== undefined; + const oppositeVisible = oppositeSet && (showUnknown || !oppositeUnknown); + if (opposite === value) { + if (to < from) { + // We already added this edge in the reverse direction. + return; + } + if (to > from) { + dir = 'both'; + } + } else if (oppositeVisible && to < from) { + // Let the opposite edge drive the graph layout. + edgeConstraint = 'false'; + edgeWeight = 0; + } else if (isUnknown && (!oppositeSet || oppositeUnknown)) { + // Only apply the UNKNOWN value penalty if we aren't the opposite + // edge driving the graph layout from above, or the penalty would + // be applied anyway. + edgeWeight *= UNKNOWN_WEIGHT_FACTOR; + } + + lines.push(`n${from} -> n${to} [ + id="${fromNode.name},${toNode.name},${relation.name}", + dir="${dir}", + constraint=${edgeConstraint}, + weight=${edgeWeight}, + xlabel=${label}, + penwidth=${penwidth}, + style="${isUnknown ? 'dashed' : 'solid'}", + class="edge-${value}" + ]`); + }); +} + +function createEdges(graph: GraphStore, lines: string[]): void { + const { + semantics: { relations }, + } = graph; + relations.forEach((relation) => { + if (relation.arity !== 2) { + return; + } + const visibility = graph.getVisiblity(relation.name); + if (visibility !== 'none') { + createRelationEdges(graph, relation, visibility === 'all', lines); + } + }); +} + +export default function dotSource( + graph: GraphStore | undefined, +): string | undefined { + if (graph === undefined) { + return undefined; + } + const lines = [ + 'digraph {', + 'graph [bgcolor=transparent];', + `node [fontsize=12, shape=plain, fontname="OpenSans"];`, + 'edge [fontsize=10.5, color=black, fontname="OpenSans"];', + ]; + createNodes(graph, lines); + createEdges(graph, lines); + lines.push('}'); + return lines.join('\n'); +} diff --git a/subprojects/frontend/src/graph/postProcessSVG.ts b/subprojects/frontend/src/graph/postProcessSVG.ts index 59cc15b9..13e4eb29 100644 --- a/subprojects/frontend/src/graph/postProcessSVG.ts +++ b/subprojects/frontend/src/graph/postProcessSVG.ts @@ -7,19 +7,48 @@ import { type BBox, parsePolygonBBox, parsePathBBox } from './parseBBox'; const SVG_NS = 'http://www.w3.org/2000/svg'; +const XLINK_NS = 'http://www.w3.org/1999/xlink'; + +function modifyAttribute(element: Element, attribute: string, change: number) { + const valueString = element.getAttribute(attribute); + if (valueString === null) { + return; + } + const value = parseInt(valueString, 10); + element.setAttribute(attribute, String(value + change)); +} + +function addShadow( + node: SVGGElement, + container: SVGRectElement, + offset: number, +): void { + const shadow = container.cloneNode() as SVGRectElement; + // Leave space for 1pt stroke around the original container. + const offsetWithStroke = offset - 0.5; + modifyAttribute(shadow, 'x', offsetWithStroke); + modifyAttribute(shadow, 'y', offsetWithStroke); + modifyAttribute(shadow, 'width', 1); + modifyAttribute(shadow, 'height', 1); + modifyAttribute(shadow, 'rx', 0.5); + modifyAttribute(shadow, 'ry', 0.5); + shadow.setAttribute('class', 'node-shadow'); + shadow.id = `${node.id},shadow`; + node.insertBefore(shadow, node.firstChild); +} function clipCompartmentBackground(node: SVGGElement) { - // Background rectangle of the node created by the `<table bgcolor="green">` + // Background rectangle of the node created by the `<table bgcolor="white">` // HTML element in dot. It was transformed into a rounded rect by `fixNodeBackground`. - const container = node.querySelector<SVGRectElement>('rect[fill="green"]'); - // Background rectangle of the lower compartment created by the `<td bgcolor="white">` + const container = node.querySelector<SVGRectElement>('rect[fill="white"]'); + // Background rectangle of the lower compartment created by the `<td bgcolor="green">` // HTML element in dot. It was transformed into a rounded rect by `fixNodeBackground`. // Since dot doesn't round the coners of `<td>` background, // we have to clip it ourselves. - const compartment = node.querySelector<SVGPolygonElement>( - 'polygon[fill="white"]', - ); - if (container === null || compartment === null) { + const compartment = node.querySelector<SVGRectElement>('rect[fill="green"]'); + // Make sure we provide traceability with IDs also for the border. + const border = node.querySelector<SVGRectElement>('rect[stroke="black"]'); + if (container === null || compartment === null || border === null) { return; } const copyOfContainer = container.cloneNode() as SVGRectElement; @@ -29,6 +58,17 @@ function clipCompartmentBackground(node: SVGGElement) { clipPath.appendChild(copyOfContainer); node.appendChild(clipPath); compartment.setAttribute('clip-path', `url(#${clipId})`); + // Enlarge the compartment to completely cover the background. + modifyAttribute(compartment, 'y', -5); + modifyAttribute(compartment, 'x', -5); + modifyAttribute(compartment, 'width', 10); + modifyAttribute(compartment, 'height', 5); + if (node.classList.contains('node-equalsSelf-UNKNOWN')) { + addShadow(node, container, 6); + } + container.id = `${node.id},container`; + compartment.id = `${node.id},compartment`; + border.id = `${node.id},border`; } function createRect( @@ -51,7 +91,7 @@ function optimizeNodeShapes(node: SVGGElement) { const rect = createRect(bbox, path); rect.setAttribute('rx', '12'); rect.setAttribute('ry', '12'); - node.replaceChild(rect, path); + path.parentNode?.replaceChild(rect, path); }); node.querySelectorAll('polygon').forEach((polygon) => { const bbox = parsePolygonBBox(polygon); @@ -62,18 +102,83 @@ function optimizeNodeShapes(node: SVGGElement) { 'points', `${bbox.x},${bbox.y} ${bbox.x + bbox.width},${bbox.y}`, ); - node.replaceChild(polyline, polygon); + polygon.parentNode?.replaceChild(polyline, polygon); } else { const rect = createRect(bbox, polygon); - node.replaceChild(rect, polygon); + polygon.parentNode?.replaceChild(rect, polygon); } }); clipCompartmentBackground(node); } +function hrefToClass(node: SVGGElement) { + node.querySelectorAll<SVGAElement>('a').forEach((a) => { + if (a.parentNode === null) { + return; + } + const href = a.getAttribute('href') ?? a.getAttributeNS(XLINK_NS, 'href'); + if (href === 'undefined' || !href?.startsWith('#')) { + return; + } + while (a.lastChild !== null) { + const child = a.lastChild; + a.removeChild(child); + if (child.nodeType === Node.ELEMENT_NODE) { + const element = child as Element; + element.classList.add('label', `label-${href.replace('#', '')}`); + a.after(child); + } + } + a.parentNode.removeChild(a); + }); +} + +function replaceImages(node: SVGGElement) { + node.querySelectorAll<SVGImageElement>('image').forEach((image) => { + const href = + image.getAttribute('href') ?? image.getAttributeNS(XLINK_NS, 'href'); + if (href === 'undefined' || !href?.startsWith('#')) { + return; + } + const width = image.getAttribute('width')?.replace('px', '') ?? ''; + const height = image.getAttribute('height')?.replace('px', '') ?? ''; + const foreign = document.createElementNS(SVG_NS, 'foreignObject'); + foreign.setAttribute('x', image.getAttribute('x') ?? ''); + foreign.setAttribute('y', image.getAttribute('y') ?? ''); + foreign.setAttribute('width', width); + foreign.setAttribute('height', height); + const div = document.createElement('div'); + div.classList.add('icon', `icon-${href.replace('#', '')}`); + foreign.appendChild(div); + const sibling = image.nextElementSibling; + // Since dot doesn't respect the `id` attribute on table cells with a single image, + // compute the ID based on the ID of the next element (the label). + if ( + sibling !== null && + sibling.tagName.toLowerCase() === 'g' && + sibling.id !== '' + ) { + foreign.id = `${sibling.id},icon`; + } + image.parentNode?.replaceChild(foreign, image); + }); +} + export default function postProcessSvg(svg: SVGSVGElement) { - svg - .querySelectorAll<SVGTitleElement>('title') - .forEach((title) => title.parentNode?.removeChild(title)); - svg.querySelectorAll<SVGGElement>('g.node').forEach(optimizeNodeShapes); + // svg + // .querySelectorAll<SVGTitleElement>('title') + // .forEach((title) => title.parentElement?.removeChild(title)); + svg.querySelectorAll<SVGGElement>('g.node').forEach((node) => { + optimizeNodeShapes(node); + hrefToClass(node); + replaceImages(node); + }); + // Increase padding to fit box shadows for multi-objects. + const viewBox = [ + svg.viewBox.baseVal.x - 6, + svg.viewBox.baseVal.y - 6, + svg.viewBox.baseVal.width + 12, + svg.viewBox.baseVal.height + 12, + ]; + svg.setAttribute('viewBox', viewBox.join(' ')); } diff --git a/subprojects/frontend/src/utils/svgURL.ts b/subprojects/frontend/src/utils/svgURL.ts new file mode 100644 index 00000000..9b8ecbd5 --- /dev/null +++ b/subprojects/frontend/src/utils/svgURL.ts @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> + * + * SPDX-License-Identifier: EPL-2.0 + */ + +export default function svgURL(svg: string): string { + return `url('data:image/svg+xml;utf8,${svg}')`; +} diff --git a/subprojects/frontend/src/xtext/xtextServiceResults.ts b/subprojects/frontend/src/xtext/xtextServiceResults.ts index 12f87b26..caf2cf0b 100644 --- a/subprojects/frontend/src/xtext/xtextServiceResults.ts +++ b/subprojects/frontend/src/xtext/xtextServiceResults.ts @@ -126,8 +126,36 @@ export const FormattingResult = DocumentStateResult.extend({ export type FormattingResult = z.infer<typeof FormattingResult>; +export const NodeMetadata = z.object({ + name: z.string(), + simpleName: z.string(), + kind: z.enum(['IMPLICIT', 'INDIVIDUAL', 'NEW']), +}); + +export type NodeMetadata = z.infer<typeof NodeMetadata>; + +export const RelationMetadata = z.object({ + name: z.string(), + simpleName: z.string(), + arity: z.number().nonnegative(), + detail: z.union([ + z.object({ type: z.literal('class'), abstractClass: z.boolean() }), + z.object({ type: z.literal('reference'), containment: z.boolean() }), + z.object({ + type: z.literal('opposite'), + container: z.boolean(), + opposite: z.string(), + }), + z.object({ type: z.literal('predicate'), error: z.boolean() }), + z.object({ type: z.literal('builtin') }), + ]), +}); + +export type RelationMetadata = z.infer<typeof RelationMetadata>; + export const SemanticsSuccessResult = z.object({ - nodes: z.string().nullable().array(), + nodes: NodeMetadata.array(), + relations: RelationMetadata.array(), partialInterpretation: z.record( z.string(), z.union([z.number(), z.string()]).array().array(), diff --git a/subprojects/frontend/vite.config.ts b/subprojects/frontend/vite.config.ts index 82e432de..63d5245f 100644 --- a/subprojects/frontend/vite.config.ts +++ b/subprojects/frontend/vite.config.ts @@ -30,7 +30,7 @@ const { mode, isDevelopment, devModePlugins, serverOptions } = process.env['NODE_ENV'] ??= mode; const fontsGlob = [ - 'open-sans-latin-wdth-normal-*.woff2', + 'open-sans-latin-wdth-{normal,italic}-*.woff2', 'jetbrains-mono-latin-wght-{normal,italic}-*.woff2', ]; diff --git a/subprojects/language-ide/src/main/java/tools/refinery/language/ide/contentassist/ProblemCrossrefProposalProvider.java b/subprojects/language-ide/src/main/java/tools/refinery/language/ide/contentassist/ProblemCrossrefProposalProvider.java index ce5e7dad..ea90a82e 100644 --- a/subprojects/language-ide/src/main/java/tools/refinery/language/ide/contentassist/ProblemCrossrefProposalProvider.java +++ b/subprojects/language-ide/src/main/java/tools/refinery/language/ide/contentassist/ProblemCrossrefProposalProvider.java @@ -36,7 +36,10 @@ public class ProblemCrossrefProposalProvider extends IdeCrossrefProposalProvider var eObjectDescriptionsByName = new HashMap<QualifiedName, List<IEObjectDescription>>(); for (var candidate : super.queryScope(scope, crossReference, context)) { if (isExistingObject(candidate, crossReference, context)) { - var qualifiedName = candidate.getQualifiedName(); + // {@code getQualifiedName()} will refer to the full name for objects that are loaded from the global + // scope, but {@code getName()} returns the qualified name that we set in + // {@code ProblemResourceDescriptionStrategy}. + var qualifiedName = candidate.getName(); var candidateList = eObjectDescriptionsByName.computeIfAbsent(qualifiedName, ignored -> new ArrayList<>()); candidateList.add(candidate); diff --git a/subprojects/language-ide/src/main/java/tools/refinery/language/ide/syntaxcoloring/ProblemSemanticHighlightingCalculator.java b/subprojects/language-ide/src/main/java/tools/refinery/language/ide/syntaxcoloring/ProblemSemanticHighlightingCalculator.java index 08747ec5..ae8c70e0 100644 --- a/subprojects/language-ide/src/main/java/tools/refinery/language/ide/syntaxcoloring/ProblemSemanticHighlightingCalculator.java +++ b/subprojects/language-ide/src/main/java/tools/refinery/language/ide/syntaxcoloring/ProblemSemanticHighlightingCalculator.java @@ -95,7 +95,7 @@ public class ProblemSemanticHighlightingCalculator extends DefaultSemanticHighli } protected String[] getHighlightClass(EObject eObject, EReference reference) { - boolean isError = eObject instanceof PredicateDefinition predicateDefinition && predicateDefinition.isError(); + boolean isError = ProblemUtil.isError(eObject); if (ProblemUtil.isBuiltIn(eObject)) { var className = isError ? ERROR_CLASS : BUILTIN_CLASS; return new String[] { className }; diff --git a/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/BuiltInDetail.java b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/BuiltInDetail.java new file mode 100644 index 00000000..6f706069 --- /dev/null +++ b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/BuiltInDetail.java @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> + * + * SPDX-License-Identifier: EPL-2.0 + */ +package tools.refinery.language.semantics.metadata; + +public record BuiltInDetail() implements RelationDetail { + public static final BuiltInDetail INSTANCE = new BuiltInDetail(); +} diff --git a/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/ClassDetail.java b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/ClassDetail.java new file mode 100644 index 00000000..1d3190f5 --- /dev/null +++ b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/ClassDetail.java @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> + * + * SPDX-License-Identifier: EPL-2.0 + */ +package tools.refinery.language.semantics.metadata; + +public record ClassDetail(boolean abstractClass) implements RelationDetail { + public static final ClassDetail CONCRETE_CLASS = new ClassDetail(false); + + public static final ClassDetail ABSTRACT_CLASS = new ClassDetail(true); + + public static ClassDetail ofAbstractClass(boolean abstractClass) { + return abstractClass ? ABSTRACT_CLASS : CONCRETE_CLASS; + } +} diff --git a/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/Metadata.java b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/Metadata.java index 811ac2c0..d2dcb43a 100644 --- a/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/Metadata.java +++ b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/Metadata.java @@ -6,7 +6,7 @@ package tools.refinery.language.semantics.metadata; public sealed interface Metadata permits NodeMetadata, RelationMetadata { - String fullyQualifiedName(); + String name(); String simpleName(); } diff --git a/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/MetadataCreator.java b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/MetadataCreator.java new file mode 100644 index 00000000..0c18b1b3 --- /dev/null +++ b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/MetadataCreator.java @@ -0,0 +1,181 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> + * + * SPDX-License-Identifier: EPL-2.0 + */ +package tools.refinery.language.semantics.metadata; + +import com.google.inject.Inject; +import org.eclipse.emf.ecore.EObject; +import org.eclipse.xtext.naming.IQualifiedNameConverter; +import org.eclipse.xtext.naming.IQualifiedNameProvider; +import org.eclipse.xtext.naming.QualifiedName; +import org.eclipse.xtext.scoping.IScope; +import org.eclipse.xtext.scoping.IScopeProvider; +import tools.refinery.language.model.problem.*; +import tools.refinery.language.semantics.model.ModelInitializer; +import tools.refinery.language.semantics.model.TracedException; +import tools.refinery.language.utils.ProblemUtil; +import tools.refinery.store.reasoning.representation.PartialRelation; + +import java.util.*; + +public class MetadataCreator { + @Inject + private IScopeProvider scopeProvider; + + @Inject + private IQualifiedNameProvider qualifiedNameProvider; + + @Inject + private IQualifiedNameConverter qualifiedNameConverter; + + private ModelInitializer initializer; + + private IScope nodeScope; + + private IScope relationScope; + + public void setInitializer(ModelInitializer initializer) { + if (initializer == null) { + throw new IllegalArgumentException("Initializer was already set"); + } + this.initializer = initializer; + var problem = initializer.getProblem(); + nodeScope = scopeProvider.getScope(problem, ProblemPackage.Literals.NODE_ASSERTION_ARGUMENT__NODE); + relationScope = scopeProvider.getScope(problem, ProblemPackage.Literals.ASSERTION__RELATION); + } + + public List<NodeMetadata> getNodesMetadata() { + var nodes = new NodeMetadata[initializer.getNodeCount()]; + for (var entry : initializer.getNodeTrace().keyValuesView()) { + var node = entry.getOne(); + var id = entry.getTwo(); + nodes[id] = getNodeMetadata(node); + } + return List.of(nodes); + } + + private NodeMetadata getNodeMetadata(Node node) { + var qualifiedName = getQualifiedName(node); + var simpleName = getSimpleName(node, qualifiedName, nodeScope); + return new NodeMetadata(qualifiedNameConverter.toString(qualifiedName), + qualifiedNameConverter.toString(simpleName), getNodeKind(node)); + } + + private NodeKind getNodeKind(Node node) { + if (ProblemUtil.isImplicitNode(node)) { + return NodeKind.IMPLICIT; + } else if (ProblemUtil.isIndividualNode(node)) { + return NodeKind.INDIVIDUAL; + } else if (ProblemUtil.isNewNode(node)) { + return NodeKind.NEW; + } else { + throw new TracedException(node, "Unknown node type"); + } + } + + public List<RelationMetadata> getRelationsMetadata() { + var relationTrace = initializer.getRelationTrace(); + var relations = new ArrayList<RelationMetadata>(relationTrace.size()); + for (var entry : relationTrace.entrySet()) { + var relation = entry.getKey(); + var partialRelation = entry.getValue(); + var metadata = getRelationMetadata(relation, partialRelation); + relations.add(metadata); + } + return Collections.unmodifiableList(relations); + } + + private RelationMetadata getRelationMetadata(Relation relation, PartialRelation partialRelation) { + var qualifiedName = getQualifiedName(relation); + var qualifiedNameString = qualifiedNameConverter.toString(qualifiedName); + var simpleName = getSimpleName(relation, qualifiedName, relationScope); + var simpleNameString = qualifiedNameConverter.toString(simpleName); + var arity = partialRelation.arity(); + var detail = getRelationDetail(relation, partialRelation); + return new RelationMetadata(qualifiedNameString, simpleNameString, arity, detail); + } + + private RelationDetail getRelationDetail(Relation relation, PartialRelation partialRelation) { + if (ProblemUtil.isBuiltIn(relation) && !ProblemUtil.isError(relation)) { + return getBuiltInDetail(); + } + if (relation instanceof ClassDeclaration classDeclaration) { + return getClassDetail(classDeclaration); + } else if (relation instanceof ReferenceDeclaration) { + return getReferenceDetail(partialRelation); + } else if (relation instanceof EnumDeclaration) { + return getEnumDetail(); + } else if (relation instanceof PredicateDefinition predicateDefinition) { + return getPredicateDetail(predicateDefinition); + } else { + throw new TracedException(relation, "Unknown relation"); + } + } + + private RelationDetail getBuiltInDetail() { + return BuiltInDetail.INSTANCE; + } + + private RelationDetail getClassDetail(ClassDeclaration classDeclaration) { + return ClassDetail.ofAbstractClass(classDeclaration.isAbstract()); + } + + private RelationDetail getReferenceDetail(PartialRelation partialRelation) { + var metamodel = initializer.getMetamodel(); + var opposite = metamodel.oppositeReferences().get(partialRelation); + if (opposite == null) { + boolean isContainment = metamodel.containmentHierarchy().containsKey(partialRelation); + return ReferenceDetail.ofContainment(isContainment); + } else { + boolean isContainer = metamodel.containmentHierarchy().containsKey(opposite); + return new OppositeReferenceDetail(isContainer, opposite.name()); + } + } + + private RelationDetail getEnumDetail() { + return ClassDetail.CONCRETE_CLASS; + } + + private RelationDetail getPredicateDetail(PredicateDefinition predicate) { + return PredicateDetail.ofError(predicate.isError()); + } + + private QualifiedName getQualifiedName(EObject eObject) { + var qualifiedName = qualifiedNameProvider.getFullyQualifiedName(eObject); + if (qualifiedName == null) { + throw new TracedException(eObject, "Unknown qualified name"); + } + return qualifiedName; + } + + private QualifiedName getSimpleName(EObject eObject, QualifiedName qualifiedName, IScope scope) { + var descriptions = scope.getElements(eObject); + var names = new HashSet<QualifiedName>(); + for (var description : descriptions) { + // {@code getQualifiedName()} will refer to the full name for objects that are loaded from the global + // scope, but {@code getName()} returns the qualified name that we set in + // {@code ProblemResourceDescriptionStrategy}. + names.add(description.getName()); + } + var iterator = names.stream().sorted(Comparator.comparingInt(QualifiedName::getSegmentCount)).iterator(); + while (iterator.hasNext()) { + var simpleName = iterator.next(); + if (names.contains(simpleName) && isUnique(scope, simpleName)) { + return simpleName; + } + } + throw new TracedException(eObject, "Ambiguous qualified name: " + + qualifiedNameConverter.toString(qualifiedName)); + } + + private boolean isUnique(IScope scope, QualifiedName name) { + var iterator = scope.getElements(name).iterator(); + if (!iterator.hasNext()) { + return false; + } + iterator.next(); + return !iterator.hasNext(); + } +} diff --git a/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/NodeKind.java b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/NodeKind.java index 27a86cb3..01f0cd09 100644 --- a/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/NodeKind.java +++ b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/NodeKind.java @@ -8,5 +8,5 @@ package tools.refinery.language.semantics.metadata; public enum NodeKind { IMPLICIT, INDIVIDUAL, - ENUM_LITERAL + NEW } diff --git a/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/NodeMetadata.java b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/NodeMetadata.java index 8d91273c..812952c0 100644 --- a/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/NodeMetadata.java +++ b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/NodeMetadata.java @@ -5,5 +5,5 @@ */ package tools.refinery.language.semantics.metadata; -public record NodeMetadata(String fullyQualifiedName, String simpleName, NodeKind kind) implements Metadata { +public record NodeMetadata(String name, String simpleName, NodeKind kind) implements Metadata { } diff --git a/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/OppositeReferenceDetail.java b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/OppositeReferenceDetail.java new file mode 100644 index 00000000..26d7461c --- /dev/null +++ b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/OppositeReferenceDetail.java @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> + * + * SPDX-License-Identifier: EPL-2.0 + */ +package tools.refinery.language.semantics.metadata; + +public record OppositeReferenceDetail(boolean container, String opposite) implements RelationDetail { +} diff --git a/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/PredicateDetail.java b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/PredicateDetail.java new file mode 100644 index 00000000..ca397eca --- /dev/null +++ b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/PredicateDetail.java @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> + * + * SPDX-License-Identifier: EPL-2.0 + */ +package tools.refinery.language.semantics.metadata; + +public record PredicateDetail(boolean error) implements RelationDetail { + public static final PredicateDetail PREDICATE = new PredicateDetail(false); + + public static final PredicateDetail ERROR_PREDICATE = new PredicateDetail(true); + + public static PredicateDetail ofError(boolean error) { + return error ? ERROR_PREDICATE : PREDICATE; + } +} diff --git a/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/ReferenceDetail.java b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/ReferenceDetail.java new file mode 100644 index 00000000..36771566 --- /dev/null +++ b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/ReferenceDetail.java @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> + * + * SPDX-License-Identifier: EPL-2.0 + */ +package tools.refinery.language.semantics.metadata; + +public record ReferenceDetail(boolean containment) implements RelationDetail { + public static final ReferenceDetail CROSS_REFERENCE = new ReferenceDetail(false); + + public static final ReferenceDetail CONTAINMENT_REFERENCE = new ReferenceDetail(true); + + public static ReferenceDetail ofContainment(boolean containment) { + return containment ? CONTAINMENT_REFERENCE : CROSS_REFERENCE; + } +} diff --git a/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/RelationDetail.java b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/RelationDetail.java new file mode 100644 index 00000000..105179fd --- /dev/null +++ b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/RelationDetail.java @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> + * + * SPDX-License-Identifier: EPL-2.0 + */ +package tools.refinery.language.semantics.metadata; + +public sealed interface RelationDetail permits ClassDetail, ReferenceDetail, PredicateDetail, OppositeReferenceDetail, + BuiltInDetail { +} diff --git a/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/RelationKind.java b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/RelationKind.java deleted file mode 100644 index 28a3c565..00000000 --- a/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/RelationKind.java +++ /dev/null @@ -1,18 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 The Refinery Authors <https://refinery.tools/> - * - * SPDX-License-Identifier: EPL-2.0 - */ -package tools.refinery.language.semantics.metadata; - -public enum RelationKind { - BUILTIN, - CLASS, - ENUM, - REFERENCE, - OPPOSITE, - CONTAINMENT, - CONTAINER, - PREDICATE, - ERROR -} diff --git a/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/RelationMetadata.java b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/RelationMetadata.java index 62de6031..5abcc253 100644 --- a/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/RelationMetadata.java +++ b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/metadata/RelationMetadata.java @@ -5,6 +5,5 @@ */ package tools.refinery.language.semantics.metadata; -public record RelationMetadata(String fullyQualifiedName, String simpleName, int arity, RelationKind kind, - String opposite) implements Metadata { +public record RelationMetadata(String name, String simpleName, int arity, RelationDetail detail) implements Metadata { } diff --git a/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/model/ModelInitializer.java b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/model/ModelInitializer.java index 82746aee..aaef3326 100644 --- a/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/model/ModelInitializer.java +++ b/subprojects/language-semantics/src/main/java/tools/refinery/language/semantics/model/ModelInitializer.java @@ -64,7 +64,7 @@ public class ModelInitializer { private final Map<PartialRelation, RelationInfo> partialRelationInfoMap = new HashMap<>(); - private Map<AnyPartialSymbol, Relation> inverseTrace = new HashMap<>(); + private final Map<AnyPartialSymbol, Relation> inverseTrace = new HashMap<>(); private Map<Relation, PartialRelation> relationTrace; @@ -74,6 +74,10 @@ public class ModelInitializer { private ModelSeed modelSeed; + public Problem getProblem() { + return problem; + } + public int getNodeCount() { return nodeTrace.size(); } @@ -90,6 +94,10 @@ public class ModelInitializer { return inverseTrace.get(partialRelation); } + public Metamodel getMetamodel() { + return metamodel; + } + public ModelSeed createModel(Problem problem, ModelStoreBuilder storeBuilder) { this.problem = problem; this.storeBuilder = storeBuilder; diff --git a/subprojects/language-web/build.gradle.kts b/subprojects/language-web/build.gradle.kts index 547cb089..a4ccdd9f 100644 --- a/subprojects/language-web/build.gradle.kts +++ b/subprojects/language-web/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { implementation(project(":refinery-language-ide")) implementation(project(":refinery-language-semantics")) implementation(project(":refinery-store-query-viatra")) + implementation(libs.gson) implementation(libs.jetty.server) implementation(libs.jetty.servlet) implementation(libs.jetty.websocket.api) diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java index 7b48cde8..e98d115e 100644 --- a/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/ProblemWebSocketServlet.java @@ -10,8 +10,10 @@ import org.eclipse.xtext.util.DisposableRegistry; import jakarta.servlet.ServletException; import tools.refinery.language.web.xtext.servlet.XtextWebSocketServlet; -public class ProblemWebSocketServlet extends XtextWebSocketServlet { +import java.io.Serial; +public class ProblemWebSocketServlet extends XtextWebSocketServlet { + @Serial private static final long serialVersionUID = -7040955470384797008L; private transient DisposableRegistry disposableRegistry; diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/semantics/SemanticsService.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/semantics/SemanticsService.java index 56b2cbc1..ba55dc77 100644 --- a/subprojects/language-web/src/main/java/tools/refinery/language/web/semantics/SemanticsService.java +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/semantics/SemanticsService.java @@ -55,7 +55,7 @@ public class SemanticsService extends AbstractCachedService<SemanticsResult> { } var problem = getProblem(doc); if (problem == null) { - return new SemanticsSuccessResult(List.of(), new JsonObject()); + return new SemanticsSuccessResult(List.of(), List.of(), new JsonObject()); } var worker = workerProvider.get(); worker.setProblem(problem, cancelIndicator); diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/semantics/SemanticsSuccessResult.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/semantics/SemanticsSuccessResult.java index 15fd4b55..350b0b2b 100644 --- a/subprojects/language-web/src/main/java/tools/refinery/language/web/semantics/SemanticsSuccessResult.java +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/semantics/SemanticsSuccessResult.java @@ -6,8 +6,11 @@ package tools.refinery.language.web.semantics; import com.google.gson.JsonObject; +import tools.refinery.language.semantics.metadata.NodeMetadata; +import tools.refinery.language.semantics.metadata.RelationMetadata; import java.util.List; -public record SemanticsSuccessResult(List<String> nodes, JsonObject partialInterpretation) implements SemanticsResult { +public record SemanticsSuccessResult(List<NodeMetadata> nodes, List<RelationMetadata> relations, + JsonObject partialInterpretation) implements SemanticsResult { } diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/semantics/SemanticsWorker.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/semantics/SemanticsWorker.java index 43d0238c..108b87dc 100644 --- a/subprojects/language-web/src/main/java/tools/refinery/language/web/semantics/SemanticsWorker.java +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/semantics/SemanticsWorker.java @@ -18,6 +18,7 @@ import org.eclipse.xtext.validation.IDiagnosticConverter; import org.eclipse.xtext.validation.Issue; import org.eclipse.xtext.web.server.validation.ValidationResult; import tools.refinery.language.model.problem.Problem; +import tools.refinery.language.semantics.metadata.MetadataCreator; import tools.refinery.language.semantics.model.ModelInitializer; import tools.refinery.language.semantics.model.SemanticsUtils; import tools.refinery.language.semantics.model.TracedException; @@ -34,8 +35,6 @@ import tools.refinery.store.tuple.Tuple; import tools.refinery.viatra.runtime.CancellationToken; import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; import java.util.TreeMap; import java.util.concurrent.Callable; @@ -54,6 +53,9 @@ class SemanticsWorker implements Callable<SemanticsResult> { @Inject private ModelInitializer initializer; + @Inject + private MetadataCreator metadataCreator; + private Problem problem; private CancellationToken cancellationToken; @@ -78,7 +80,11 @@ class SemanticsWorker implements Callable<SemanticsResult> { try { var modelSeed = initializer.createModel(problem, builder); cancellationToken.checkCancelled(); - var nodeTrace = getNodeTrace(initializer); + metadataCreator.setInitializer(initializer); + cancellationToken.checkCancelled(); + var nodesMetadata = metadataCreator.getNodesMetadata(); + cancellationToken.checkCancelled(); + var relationsMetadata = metadataCreator.getRelationsMetadata(); cancellationToken.checkCancelled(); var store = builder.build(); cancellationToken.checkCancelled(); @@ -87,7 +93,7 @@ class SemanticsWorker implements Callable<SemanticsResult> { cancellationToken.checkCancelled(); var partialInterpretation = getPartialInterpretation(initializer, model); - return new SemanticsSuccessResult(nodeTrace, partialInterpretation); + return new SemanticsSuccessResult(nodesMetadata, relationsMetadata, partialInterpretation); } catch (TracedException e) { return getTracedErrorResult(e.getSourceElement(), e.getMessage()); } catch (TranslationException e) { @@ -96,16 +102,6 @@ class SemanticsWorker implements Callable<SemanticsResult> { } } - private List<String> getNodeTrace(ModelInitializer initializer) { - var nodeTrace = new String[initializer.getNodeCount()]; - for (var entry : initializer.getNodeTrace().keyValuesView()) { - var node = entry.getOne(); - var index = entry.getTwo(); - nodeTrace[index] = semanticsUtils.getName(node).orElse(null); - } - return Arrays.asList(nodeTrace); - } - private JsonObject getPartialInterpretation(ModelInitializer initializer, Model model) { var adapter = model.getAdapter(ReasoningAdapter.class); var json = new JsonObject(); diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java index ff788e94..7c4562bf 100644 --- a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/server/message/XtextWebRequest.java @@ -5,19 +5,22 @@ */ package tools.refinery.language.web.xtext.server.message; +import com.google.gson.annotations.SerializedName; + import java.util.Map; import java.util.Objects; -import com.google.gson.annotations.SerializedName; - public class XtextWebRequest { private String id; @SerializedName("request") private Map<String, String> requestData; + public XtextWebRequest() { + this(null, null); + } + public XtextWebRequest(String id, Map<String, String> requestData) { - super(); this.id = id; this.requestData = requestData; } diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/RuntimeTypeAdapterFactory.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/RuntimeTypeAdapterFactory.java new file mode 100644 index 00000000..b16cf7df --- /dev/null +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/RuntimeTypeAdapterFactory.java @@ -0,0 +1,304 @@ +/* + * Copyright (C) 2011 Google Inc. + * Copyright (C) 2023 The Refinery Authors <https://refinery.tools/> + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * This file was copied into Refinery according to upstream instructions at + * https://github.com/google/gson/issues/1104#issuecomment-309582470. + * However, we changed the package name below to avoid potential clashes + * with other jars on the classpath. + */ +package tools.refinery.language.web.xtext.servlet; + +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonPrimitive; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Adapts values whose runtime type may differ from their declaration type. This + * is necessary when a field's type is not the same type that GSON should create + * when deserializing that field. For example, consider these types: + * <pre> {@code + * abstract class Shape { + * int x; + * int y; + * } + * class Circle extends Shape { + * int radius; + * } + * class Rectangle extends Shape { + * int width; + * int height; + * } + * class Diamond extends Shape { + * int width; + * int height; + * } + * class Drawing { + * Shape bottomShape; + * Shape topShape; + * } + * }</pre> + * <p>Without additional type information, the serialized JSON is ambiguous. Is + * the bottom shape in this drawing a rectangle or a diamond? <pre> {@code + * { + * "bottomShape": { + * "width": 10, + * "height": 5, + * "x": 0, + * "y": 0 + * }, + * "topShape": { + * "radius": 2, + * "x": 4, + * "y": 1 + * } + * }}</pre> + * This class addresses this problem by adding type information to the + * serialized JSON and honoring that type information when the JSON is + * deserialized: <pre> {@code + * { + * "bottomShape": { + * "type": "Diamond", + * "width": 10, + * "height": 5, + * "x": 0, + * "y": 0 + * }, + * "topShape": { + * "type": "Circle", + * "radius": 2, + * "x": 4, + * "y": 1 + * } + * }}</pre> + * Both the type field name ({@code "type"}) and the type labels ({@code + * "Rectangle"}) are configurable. + * + * <h2>Registering Types</h2> + * Create a {@code RuntimeTypeAdapterFactory} by passing the base type and type field + * name to the {@link #of} factory method. If you don't supply an explicit type + * field name, {@code "type"} will be used. <pre> {@code + * RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory + * = RuntimeTypeAdapterFactory.of(Shape.class, "type"); + * }</pre> + * Next register all of your subtypes. Every subtype must be explicitly + * registered. This protects your application from injection attacks. If you + * don't supply an explicit type label, the type's simple name will be used. + * <pre> {@code + * shapeAdapterFactory.registerSubtype(Rectangle.class, "Rectangle"); + * shapeAdapterFactory.registerSubtype(Circle.class, "Circle"); + * shapeAdapterFactory.registerSubtype(Diamond.class, "Diamond"); + * }</pre> + * Finally, register the type adapter factory in your application's GSON builder: + * <pre> {@code + * Gson gson = new GsonBuilder() + * .registerTypeAdapterFactory(shapeAdapterFactory) + * .create(); + * }</pre> + * Like {@code GsonBuilder}, this API supports chaining: <pre> {@code + * RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory = RuntimeTypeAdapterFactory.of(Shape.class) + * .registerSubtype(Rectangle.class) + * .registerSubtype(Circle.class) + * .registerSubtype(Diamond.class); + * }</pre> + * + * <h2>Serialization and deserialization</h2> + * In order to serialize and deserialize a polymorphic object, + * you must specify the base type explicitly. + * <pre> {@code + * Diamond diamond = new Diamond(); + * String json = gson.toJson(diamond, Shape.class); + * }</pre> + * And then: + * <pre> {@code + * Shape shape = gson.fromJson(json, Shape.class); + * }</pre> + */ +public final class RuntimeTypeAdapterFactory<T> implements TypeAdapterFactory { + private final Class<?> baseType; + private final String typeFieldName; + private final Map<String, Class<?>> labelToSubtype = new LinkedHashMap<>(); + private final Map<Class<?>, String> subtypeToLabel = new LinkedHashMap<>(); + private final boolean maintainType; + private boolean recognizeSubtypes; + + private RuntimeTypeAdapterFactory( + Class<?> baseType, String typeFieldName, boolean maintainType) { + if (typeFieldName == null || baseType == null) { + throw new NullPointerException(); + } + this.baseType = baseType; + this.typeFieldName = typeFieldName; + this.maintainType = maintainType; + } + + /** + * Creates a new runtime type adapter using for {@code baseType} using {@code + * typeFieldName} as the type field name. Type field names are case sensitive. + * + * @param maintainType true if the type field should be included in deserialized objects + */ + public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType, String typeFieldName, boolean maintainType) { + return new RuntimeTypeAdapterFactory<>(baseType, typeFieldName, maintainType); + } + + /** + * Creates a new runtime type adapter using for {@code baseType} using {@code + * typeFieldName} as the type field name. Type field names are case sensitive. + */ + public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType, String typeFieldName) { + return new RuntimeTypeAdapterFactory<>(baseType, typeFieldName, false); + } + + /** + * Creates a new runtime type adapter for {@code baseType} using {@code "type"} as + * the type field name. + */ + public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType) { + return new RuntimeTypeAdapterFactory<>(baseType, "type", false); + } + + /** + * Ensures that this factory will handle not just the given {@code baseType}, but any subtype + * of that type. + */ + @CanIgnoreReturnValue + public RuntimeTypeAdapterFactory<T> recognizeSubtypes() { + this.recognizeSubtypes = true; + return this; + } + + /** + * Registers {@code type} identified by {@code label}. Labels are case + * sensitive. + * + * @throws IllegalArgumentException if either {@code type} or {@code label} + * have already been registered on this type adapter. + */ + @CanIgnoreReturnValue + public RuntimeTypeAdapterFactory<T> registerSubtype(Class<? extends T> type, String label) { + if (type == null || label == null) { + throw new NullPointerException(); + } + if (subtypeToLabel.containsKey(type) || labelToSubtype.containsKey(label)) { + throw new IllegalArgumentException("types and labels must be unique"); + } + labelToSubtype.put(label, type); + subtypeToLabel.put(type, label); + return this; + } + + /** + * Registers {@code type} identified by its {@link Class#getSimpleName simple + * name}. Labels are case sensitive. + * + * @throws IllegalArgumentException if either {@code type} or its simple name + * have already been registered on this type adapter. + */ + @CanIgnoreReturnValue + public RuntimeTypeAdapterFactory<T> registerSubtype(Class<? extends T> type) { + return registerSubtype(type, type.getSimpleName()); + } + + @Override + public <R> TypeAdapter<R> create(Gson gson, TypeToken<R> type) { + if (type == null) { + return null; + } + Class<?> rawType = type.getRawType(); + boolean handle = + recognizeSubtypes ? baseType.isAssignableFrom(rawType) : baseType.equals(rawType); + if (!handle) { + return null; + } + + final TypeAdapter<JsonElement> jsonElementAdapter = gson.getAdapter(JsonElement.class); + final Map<String, TypeAdapter<?>> labelToDelegate = new LinkedHashMap<>(); + final Map<Class<?>, TypeAdapter<?>> subtypeToDelegate = new LinkedHashMap<>(); + for (Map.Entry<String, Class<?>> entry : labelToSubtype.entrySet()) { + TypeAdapter<?> delegate = gson.getDelegateAdapter(this, TypeToken.get(entry.getValue())); + labelToDelegate.put(entry.getKey(), delegate); + subtypeToDelegate.put(entry.getValue(), delegate); + } + + return new TypeAdapter<R>() { + @Override public R read(JsonReader in) throws IOException { + JsonElement jsonElement = jsonElementAdapter.read(in); + JsonElement labelJsonElement; + if (maintainType) { + labelJsonElement = jsonElement.getAsJsonObject().get(typeFieldName); + } else { + labelJsonElement = jsonElement.getAsJsonObject().remove(typeFieldName); + } + + if (labelJsonElement == null) { + throw new JsonParseException("cannot deserialize " + baseType + + " because it does not define a field named " + typeFieldName); + } + String label = labelJsonElement.getAsString(); + @SuppressWarnings("unchecked") // registration requires that subtype extends T + TypeAdapter<R> delegate = (TypeAdapter<R>) labelToDelegate.get(label); + if (delegate == null) { + throw new JsonParseException("cannot deserialize " + baseType + " subtype named " + + label + "; did you forget to register a subtype?"); + } + return delegate.fromJsonTree(jsonElement); + } + + @Override public void write(JsonWriter out, R value) throws IOException { + Class<?> srcType = value.getClass(); + String label = subtypeToLabel.get(srcType); + @SuppressWarnings("unchecked") // registration requires that subtype extends T + TypeAdapter<R> delegate = (TypeAdapter<R>) subtypeToDelegate.get(srcType); + if (delegate == null) { + throw new JsonParseException("cannot serialize " + srcType.getName() + + "; did you forget to register a subtype?"); + } + JsonObject jsonObject = delegate.toJsonTree(value).getAsJsonObject(); + + if (maintainType) { + jsonElementAdapter.write(out, jsonObject); + return; + } + + JsonObject clone = new JsonObject(); + + if (jsonObject.has(typeFieldName)) { + throw new JsonParseException("cannot serialize " + srcType.getName() + + " because it already defines a field named " + typeFieldName); + } + clone.add(typeFieldName, new JsonPrimitive(label)); + + for (Map.Entry<String, JsonElement> e : jsonObject.entrySet()) { + clone.add(e.getKey(), e.getValue()); + } + jsonElementAdapter.write(out, clone); + } + }.nullSafe(); + } +} diff --git a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java index 923fecd6..1fde1be5 100644 --- a/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java +++ b/subprojects/language-web/src/main/java/tools/refinery/language/web/xtext/servlet/XtextWebSocket.java @@ -6,6 +6,7 @@ package tools.refinery.language.web.xtext.servlet; import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import com.google.gson.JsonIOException; import com.google.gson.JsonParseException; import org.eclipse.jetty.websocket.api.Callback; @@ -16,6 +17,7 @@ import org.eclipse.xtext.resource.IResourceServiceProvider; import org.eclipse.xtext.web.server.ISession; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import tools.refinery.language.semantics.metadata.*; import tools.refinery.language.web.xtext.server.ResponseHandler; import tools.refinery.language.web.xtext.server.ResponseHandlerException; import tools.refinery.language.web.xtext.server.TransactionExecutor; @@ -28,7 +30,15 @@ import java.io.Reader; public class XtextWebSocket implements ResponseHandler { private static final Logger LOG = LoggerFactory.getLogger(XtextWebSocket.class); - private final Gson gson = new Gson(); + private final Gson gson = new GsonBuilder() + .disableJdkUnsafe() + .registerTypeAdapterFactory(RuntimeTypeAdapterFactory.of(RelationDetail.class, "type") + .registerSubtype(ClassDetail.class, "class") + .registerSubtype(ReferenceDetail.class, "reference") + .registerSubtype(OppositeReferenceDetail.class, "opposite") + .registerSubtype(PredicateDetail.class, "predicate") + .registerSubtype(BuiltInDetail.class, "builtin")) + .create(); private final TransactionExecutor executor; diff --git a/subprojects/language/src/main/java/tools/refinery/language/utils/ProblemUtil.java b/subprojects/language/src/main/java/tools/refinery/language/utils/ProblemUtil.java index 03b0c729..a9efc4bb 100644 --- a/subprojects/language/src/main/java/tools/refinery/language/utils/ProblemUtil.java +++ b/subprojects/language/src/main/java/tools/refinery/language/utils/ProblemUtil.java @@ -7,7 +7,6 @@ package tools.refinery.language.utils; import org.eclipse.emf.common.util.URI; import org.eclipse.emf.ecore.EObject; - import tools.refinery.language.model.problem.*; public final class ProblemUtil { @@ -50,6 +49,10 @@ public final class ProblemUtil { } } + public static boolean isError(EObject eObject) { + return eObject instanceof PredicateDefinition predicateDefinition && predicateDefinition.isError(); + } + public static boolean isIndividualNode(Node node) { var containingFeature = node.eContainingFeature(); return containingFeature == ProblemPackage.Literals.INDIVIDUAL_DECLARATION__NODES diff --git a/yarn.lock b/yarn.lock index 59835487..bc3b3de2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4004,7 +4004,7 @@ __metadata: "d3-graphviz@patch:d3-graphviz@npm%3A5.1.0#~/.yarn/patches/d3-graphviz-npm-5.1.0-ba6bed3fec.patch": version: 5.1.0 - resolution: "d3-graphviz@patch:d3-graphviz@npm%3A5.1.0#~/.yarn/patches/d3-graphviz-npm-5.1.0-ba6bed3fec.patch::version=5.1.0&hash=d00cb5" + resolution: "d3-graphviz@patch:d3-graphviz@npm%3A5.1.0#~/.yarn/patches/d3-graphviz-npm-5.1.0-ba6bed3fec.patch::version=5.1.0&hash=dcacac" dependencies: "@hpcc-js/wasm": "npm:2.13.1" d3-dispatch: "npm:^3.0.1" @@ -4016,7 +4016,7 @@ __metadata: d3-zoom: "npm:^3.0.0" peerDependencies: d3-selection: ^3.0.0 - checksum: 23e56b979950ff19f12321e9c23e56e55e791950f42ced3613581f4ac6a70e7b78b4bf3c600377df0766ee20f967741c939011b7a4d192a9eb3e2e07fa45833d + checksum: 47ac96385ebee243fa44898f0f4cd25dce49683d66955511adaf94a584ae7261a485cbcec8910709dd5a6fe857ae7b7e05abe5b1ce0f0e9b69d2691ff0b13d81 languageName: node linkType: hard -- cgit v1.2.3-54-g00ecf