Set output evaluation context for a single node (#8440)

- Closes #8072
- Implement handlers for the corresponding buttons on the circular menu
- Add missing icons and styles
- Add functionality to match and extract ASTs

# Important Notes
None
This commit is contained in:
somebody1234 2023-12-15 20:29:15 +10:00 committed by GitHub
parent b5c995a7bf
commit 927df167d7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1121 additions and 345 deletions

View File

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { getMainFile, setMainFile } from '../mock/engine'
import App from '../src/App.vue' import App from '../src/App.vue'
import MockProjectStoreWrapper from '../stories/MockProjectStoreWrapper.vue' import MockProjectStoreWrapper from '../stories/MockProjectStoreWrapper.vue'
import { getMainFile, setMainFile } from './mockEngine'
const mainFile = computed({ const mainFile = computed({
get() { get() {

View File

@ -1,13 +1,13 @@
import 'enso-dashboard/src/tailwind.css' import 'enso-dashboard/src/tailwind.css'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import { createApp, ref } from 'vue' import { createApp, ref } from 'vue'
import { mockDataHandler, mockLSHandler } from '../mock/engine'
import '../src/assets/base.css' import '../src/assets/base.css'
import { provideGuiConfig } from '../src/providers/guiConfig' import { provideGuiConfig } from '../src/providers/guiConfig'
import { provideVisualizationConfig } from '../src/providers/visualizationConfig' import { provideVisualizationConfig } from '../src/providers/visualizationConfig'
import { Vec2 } from '../src/util/data/vec2' import { Vec2 } from '../src/util/data/vec2'
import { MockTransport, MockWebSocket } from '../src/util/net' import { MockTransport, MockWebSocket } from '../src/util/net'
import MockApp from './MockApp.vue' import MockApp from './MockApp.vue'
import { mockDataHandler, mockLSHandler } from './mockEngine'
MockTransport.addMock('engine', mockLSHandler) MockTransport.addMock('engine', mockLSHandler)
MockWebSocket.addMock('data', mockDataHandler) MockWebSocket.addMock('data', mockDataHandler)

View File

@ -6,7 +6,7 @@ import {
type ProjectId, type ProjectId,
type ProjectName, type ProjectName,
type UTCDateTime, type UTCDateTime,
} from './mockProjectManager' } from '../mock/projectManager'
import pmSpec from './pm-openrpc.json' assert { type: 'json' } import pmSpec from './pm-openrpc.json' assert { type: 'json' }
export default function setup() { export default function setup() {

92
app/gui2/mock/index.ts Normal file
View File

@ -0,0 +1,92 @@
import { provideGuiConfig } from '@/providers/guiConfig'
import { provideWidgetRegistry } from '@/providers/widgetRegistry'
import { useGraphStore } from '@/stores/graph'
import { GraphDb, mockNode } from '@/stores/graph/graphDatabase'
import { useProjectStore } from '@/stores/project'
import { ComputedValueRegistry } from '@/stores/project/computedValueRegistry'
import { MockTransport, MockWebSocket } from '@/util/net'
import * as random from 'lib0/random'
import { getActivePinia } from 'pinia'
import type { ExprId } from 'shared/yjsModel'
import { ref, type App } from 'vue'
import { mockDataHandler, mockLSHandler } from './engine'
export * as providers from './providers'
export * as vue from './vue'
export function languageServer() {
MockTransport.addMock('engine', mockLSHandler)
}
export function dataServer() {
MockWebSocket.addMock('data', mockDataHandler)
}
export function guiConfig(app: App) {
return provideGuiConfig._mock(
ref({
startup: {
project: 'Mock Project',
displayedProjectName: 'Mock Project',
},
engine: { rpcUrl: 'mock://engine', dataUrl: 'mock://data' },
}),
app,
)
}
export const computedValueRegistry = ComputedValueRegistry.Mock
export const graphDb = GraphDb.Mock
export function widgetRegistry(app: App) {
return widgetRegistry.withGraphDb(graphDb())(app)
}
widgetRegistry.withGraphDb = function widgetRegistryWithGraphDb(graphDb: GraphDb) {
return (app: App) => provideWidgetRegistry._mock([graphDb], app)
}
export function graphStore() {
return useGraphStore(getActivePinia())
}
type ProjectStore = ReturnType<typeof projectStore>
export function projectStore() {
const projectStore = useProjectStore(getActivePinia())
const mod = projectStore.projectModel.createNewModule('Main.enso')
mod.doc.ydoc.emit('load', [])
mod.doc.contents.insert(0, 'main =\n')
return projectStore
}
/** The stores should be initialized in this order, as `graphStore` depends on `projectStore`. */
export function projectStoreAndGraphStore() {
return [projectStore(), graphStore()] satisfies [] | unknown[]
}
export function newExprId() {
return random.uuidv4() as ExprId
}
/** This should only be used for supplying as initial props when testing.
* Please do {@link GraphDb.mockNode} with a `useGraphStore().db` after mount. */
export function node() {
return mockNode()
}
export function waitForMainModule(projectStore?: ProjectStore) {
const definedProjectStore = projectStore ?? useProjectStore(getActivePinia())
return new Promise((resolve, reject) => {
const handle1 = window.setInterval(() => {
if (definedProjectStore.module != null) {
window.clearInterval(handle1)
window.clearTimeout(handle2)
resolve(definedProjectStore.module)
}
}, 10)
const handle2 = window.setTimeout(() => {
window.clearInterval(handle1)
reject()
}, 5_000)
})
}

View File

@ -1,11 +1,11 @@
declare const projectIdBrand: unique symbol declare const projectIdBrand: unique symbol
/** A name of a project. */ /** An ID of a project. */
export type ProjectId = string & { [projectIdBrand]: never } export type ProjectId = string & { [projectIdBrand]: never }
declare const projectNameBrand: unique symbol declare const projectNameBrand: unique symbol
/** A name of a project. */ /** A name of a project. */
export type ProjectName = string & { [projectNameBrand]: never } export type ProjectName = string & { [projectNameBrand]: never }
declare const utcDateTimeBrand: unique symbol declare const utcDateTimeBrand: unique symbol
/** A name of a project. */ /** A UTC date and time. */
export type UTCDateTime = string & { [utcDateTimeBrand]: never } export type UTCDateTime = string & { [utcDateTimeBrand]: never }
/** A value specifying the hostname and port of a socket. */ /** A value specifying the hostname and port of a socket. */

View File

@ -0,0 +1,55 @@
import type { GraphSelection } from '@/providers/graphSelection'
import type { GraphNavigator } from '../src/providers/graphNavigator'
import { Rect } from '../src/util/data/rect'
import { Vec2 } from '../src/util/data/vec2'
export const graphNavigator: GraphNavigator = {
events: {} as any,
clientToScenePos: () => Vec2.Zero,
clientToSceneRect: () => Rect.Zero,
panAndZoomTo: () => {},
transform: '',
prescaledTransform: '',
translate: Vec2.Zero,
scale: 1,
sceneMousePos: Vec2.Zero,
viewBox: '',
viewport: Rect.Zero,
}
export function graphNavigatorWith(modifications?: Partial<GraphNavigator>): GraphNavigator {
return Object.assign({}, graphNavigator, modifications)
}
export const graphSelection: GraphSelection = {
events: {} as any,
anchor: undefined,
deselectAll: () => {},
addHoveredPort: () => new Set(),
removeHoveredPort: () => false,
handleSelectionOf: () => {},
hoveredNode: undefined,
hoveredPort: undefined,
isSelected: () => false,
mouseHandler: () => false,
selectAll: () => {},
selected: new Set(),
}
export function graphSelectionWith(modifications?: Partial<GraphSelection>): GraphSelection {
return Object.assign({}, graphSelection, modifications)
}
export const all = {
'graph navigator': graphNavigator,
'graph selection': graphSelection,
}
export function allWith(
modifications: Partial<{ [K in keyof typeof all]: Partial<(typeof all)[K]> }>,
): typeof all {
return {
'graph navigator': graphNavigatorWith(modifications['graph navigator']),
'graph selection': graphSelectionWith(modifications['graph selection']),
}
}

19
app/gui2/mock/vue.ts Normal file
View File

@ -0,0 +1,19 @@
import { type VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
// It is currently not feasible to use generics here, as the type of the component's emits
// is not exposed.
export function handleEmit(wrapper: VueWrapper<any>, event: string, fn: (...args: any[]) => void) {
let previousLength = 0
return {
async run() {
const emitted = wrapper.emitted(event)
if (!emitted) return
for (let i = previousLength; i < emitted.length; i += 1) {
fn(...emitted[i]!)
}
previousLength = emitted.length
await nextTick()
},
}
}

View File

@ -1426,6 +1426,12 @@
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.9946 4L5.99463 0L5.99463 3.00002C2.68878 3.00924 0.0117188 5.69199 0.0117188 9C0.0117188 9.85105 0.188908 10.6607 0.508408 11.3941L2.25769 10.3842C2.0986 9.95285 2.01172 9.48657 2.01172 9C2.01172 6.79656 3.79335 5.00924 5.99463 5.00004V8L12.9946 4ZM11.6263 7.28463L13.3624 6.28229L15.0022 5.33554L16.0022 7.06759L13.9646 8.24403C13.9957 8.49164 14.0117 8.74395 14.0117 9C14.0117 12.3137 11.3254 15 8.01172 15H6.01172C5.05341 15 4.14757 14.7753 3.34401 14.3758L1 15.7291L0 13.9971L1.60404 13.071L3.40299 12.0323L11.6263 7.28463ZM5.74242 12.9911L11.9937 9.38188C11.8015 11.4119 10.0921 13 8.01172 13H6.01172C5.92122 13 5.83142 12.997 5.74242 12.9911Z" fill="currentColor" fill-opacity="0.6"/> <path fill-rule="evenodd" clip-rule="evenodd" d="M12.9946 4L5.99463 0L5.99463 3.00002C2.68878 3.00924 0.0117188 5.69199 0.0117188 9C0.0117188 9.85105 0.188908 10.6607 0.508408 11.3941L2.25769 10.3842C2.0986 9.95285 2.01172 9.48657 2.01172 9C2.01172 6.79656 3.79335 5.00924 5.99463 5.00004V8L12.9946 4ZM11.6263 7.28463L13.3624 6.28229L15.0022 5.33554L16.0022 7.06759L13.9646 8.24403C13.9957 8.49164 14.0117 8.74395 14.0117 9C14.0117 12.3137 11.3254 15 8.01172 15H6.01172C5.05341 15 4.14757 14.7753 3.34401 14.3758L1 15.7291L0 13.9971L1.60404 13.071L3.40299 12.0323L11.6263 7.28463ZM5.74242 12.9911L11.9937 9.38188C11.8015 11.4119 10.0921 13 8.01172 13H6.01172C5.92122 13 5.83142 12.997 5.74242 12.9911Z" fill="currentColor" fill-opacity="0.6"/>
</svg> </svg>
</g> </g>
<g id="auto_replay" fill="none">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.9829 4L6.98291 0V3.00002C3.67706 3.00924 1 5.69199 1 9C1 9.85105 1.17719 10.6607 1.49669 11.3941L3.24597 10.3842C3.08688 9.95285 3 9.48657 3 9C3 6.79656 4.78163 5.00924 6.98291 5.00004V8L13.9829 4ZM14.3506 6.28229L12.6146 7.28463C12.8617 7.80448 13 8.38609 13 9C13 11.2091 11.2091 13 9 13H7C6.00325 13 5.09164 12.6354 4.39127 12.0323L2.59232 13.071C3.68848 14.2572 5.25749 15 7 15H9C12.3137 15 15 12.3137 15 9C15 8.02176 14.7659 7.0982 14.3506 6.28229Z" fill="currentColor" fill-opacity="0.6"/>
</svg>
</g>
<g id="expanded_node" fill="none"> <g id="expanded_node" fill="none">
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" <svg width="17" height="17" viewBox="0 0 17 17" fill="none"
xmlns="http://www.w3.org/2000/svg"> xmlns="http://www.w3.org/2000/svg">

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 149 KiB

View File

@ -2,12 +2,13 @@
import ToggleIcon from '@/components/ToggleIcon.vue' import ToggleIcon from '@/components/ToggleIcon.vue'
const props = defineProps<{ const props = defineProps<{
isAutoEvaluationDisabled: boolean isOutputContextEnabledGlobally: boolean
isOutputContextOverridden: boolean
isDocsVisible: boolean isDocsVisible: boolean
isVisualizationVisible: boolean isVisualizationVisible: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
'update:isAutoEvaluationDisabled': [isAutoEvaluationDisabled: boolean] 'update:isOutputContextOverridden': [isOutputContextOverridden: boolean]
'update:isDocsVisible': [isDocsVisible: boolean] 'update:isDocsVisible': [isDocsVisible: boolean]
'update:isVisualizationVisible': [isVisualizationVisible: boolean] 'update:isVisualizationVisible': [isVisualizationVisible: boolean]
}>() }>()
@ -16,11 +17,16 @@ const emit = defineEmits<{
<template> <template>
<div class="CircularMenu"> <div class="CircularMenu">
<ToggleIcon <ToggleIcon
icon="no_auto_replay" :icon="props.isOutputContextEnabledGlobally ? 'no_auto_replay' : 'auto_replay'"
class="icon-container button no-auto-evaluate-button" class="icon-container button override-output-context-button"
:alt="`${props.isAutoEvaluationDisabled ? 'Enable' : 'Disable'} auto-evaluation`" :class="{ 'output-context-overridden': props.isOutputContextOverridden }"
:modelValue="props.isAutoEvaluationDisabled" :alt="`${
@update:modelValue="emit('update:isAutoEvaluationDisabled', $event)" props.isOutputContextEnabledGlobally != props.isOutputContextOverridden
? 'Disable'
: 'Enable'
} output context`"
:modelValue="props.isOutputContextOverridden"
@update:modelValue="emit('update:isOutputContextOverridden', $event)"
/> />
<ToggleIcon <ToggleIcon
icon="docs" icon="docs"
@ -70,12 +76,17 @@ const emit = defineEmits<{
opacity: unset; opacity: unset;
} }
.no-auto-evaluate-button { .override-output-context-button {
position: absolute; position: absolute;
left: 9px; left: 9px;
top: 8px; top: 8px;
} }
.output-context-overridden {
opacity: 100%;
color: red;
}
.docs-button { .docs-button {
position: absolute; position: absolute;
left: 18.54px; left: 18.54px;

View File

@ -5,7 +5,7 @@ import { useGraphStore, type Edge } from '@/stores/graph'
import { assert } from '@/util/assert' import { assert } from '@/util/assert'
import { Rect } from '@/util/data/rect' import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2' import { Vec2 } from '@/util/data/vec2'
import theme from '@/util/theme.json' import theme from '@/util/theme'
import { clamp } from '@vueuse/core' import { clamp } from '@vueuse/core'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
@ -79,7 +79,7 @@ const edgeColor = computed(
) )
/** The inputs to the edge state computation. */ /** The inputs to the edge state computation. */
type Inputs = { interface Inputs {
/** The width and height of the node that originates the edge, if any. /** The width and height of the node that originates the edge, if any.
* The edge may begin anywhere around the bottom half of the node. */ * The edge may begin anywhere around the bottom half of the node. */
sourceSize: Vec2 sourceSize: Vec2
@ -93,42 +93,12 @@ type Inputs = {
targetPortTopDistanceInNode: number | undefined targetPortTopDistanceInNode: number | undefined
} }
type JunctionPoints = { interface JunctionPoints {
points: Vec2[] points: Vec2[]
maxRadius: number maxRadius: number
targetAttachment: { target: Vec2; length: number } | undefined targetAttachment: { target: Vec2; length: number } | undefined
} }
/** Minimum height above the target the edge must approach it from. */
const MIN_APPROACH_HEIGHT = 32
/** The preferred arc radius. */
const RADIUS_BASE = 20
/** Constants configuring the 1-corner layout. */
const SingleCorner = {
/** The y-allocation for the radius will be the full available height minus this value. */
RADIUS_Y_ADJUSTMENT: 29,
/** The base x-allocation for the radius. */
RADIUS_X_BASE: RADIUS_BASE,
/** Proportion (0-1) of extra x-distance allocated to the radius. */
RADIUS_X_FACTOR: 0.6,
/** Distance for the line to continue under the node, to ensure that there isn't a gap. */
SOURCE_NODE_OVERLAP: 4,
/** Minimum arc radius at which we offset the source end to exit normal to the node's curve. */
MINIMUM_TANGENT_EXIT_RADIUS: 2,
} as const
/** Constants configuring the 3-corner layouts. */
const ThreeCorner = {
/** The maximum arc radius. */
RADIUS_MAX: RADIUS_BASE,
BACKWARD_EDGE_ARROW_THRESHOLD: 15,
/** The maximum radius reduction (from [`RADIUS_BASE`]) to allow when choosing whether to use
* the three-corner layout that doesn't use a backward corner.
*/
MAX_SQUEEZE: 2,
} as const
function circleIntersection(x: number, r1: number, r2: number): number { function circleIntersection(x: number, r1: number, r2: number): number {
let xNorm = clamp(x, -r2, r1) let xNorm = clamp(x, -r2, r1)
return Math.sqrt(r1 * r1 + r2 * r2 - xNorm * xNorm) return Math.sqrt(r1 * r1 + r2 * r2 - xNorm * xNorm)
@ -188,31 +158,26 @@ function junctionPoints(inputs: Inputs): JunctionPoints | null {
: undefined : undefined
const targetWellBelowSource = const targetWellBelowSource =
inputs.targetOffset.y - (inputs.targetPortTopDistanceInNode ?? 0) >= MIN_APPROACH_HEIGHT inputs.targetOffset.y - (inputs.targetPortTopDistanceInNode ?? 0) >=
theme.edge.min_approach_height
const targetBelowSource = inputs.targetOffset.y > theme.node.height / 2.0 const targetBelowSource = inputs.targetOffset.y > theme.node.height / 2.0
const targetBeyondSource = Math.abs(inputs.targetOffset.x) > sourceMaxXOffset const targetBeyondSource = Math.abs(inputs.targetOffset.x) > sourceMaxXOffset
const horizontalRoomFor3Corners = const horizontalRoomFor3Corners =
targetBeyondSource && targetBeyondSource &&
Math.abs(inputs.targetOffset.x) - sourceMaxXOffset >= Math.abs(inputs.targetOffset.x) - sourceMaxXOffset >=
3.0 * (RADIUS_BASE - ThreeCorner.MAX_SQUEEZE) 3.0 * (theme.edge.radius - theme.edge.three_corner.max_squeeze)
if (targetWellBelowSource || (targetBelowSource && !horizontalRoomFor3Corners)) { if (targetWellBelowSource || (targetBelowSource && !horizontalRoomFor3Corners)) {
const { const innerTheme = theme.edge.one_corner
RADIUS_Y_ADJUSTMENT,
RADIUS_X_BASE,
RADIUS_X_FACTOR,
SOURCE_NODE_OVERLAP,
MINIMUM_TANGENT_EXIT_RADIUS,
} = SingleCorner
// The edge can originate anywhere along the length of the node. // The edge can originate anywhere along the length of the node.
const sourceX = clamp(inputs.targetOffset.x, -sourceMaxXOffset, sourceMaxXOffset) const sourceX = clamp(inputs.targetOffset.x, -sourceMaxXOffset, sourceMaxXOffset)
const distanceX = Math.max(Math.abs(inputs.targetOffset.x) - halfSourceSize.x, 0) const distanceX = Math.max(Math.abs(inputs.targetOffset.x) - halfSourceSize.x, 0)
const radiusX = RADIUS_X_BASE + distanceX * RADIUS_X_FACTOR const radiusX = innerTheme.radius_x_base + distanceX * innerTheme.radius_x_factor
// The minimum length of straight line there should be at the target end of the edge. This // The minimum length of straight line there should be at the target end of the edge. This
// is a fixed value, except it is reduced when the target is horizontally very close to the // is a fixed value, except it is reduced when the target is horizontally very close to the
// edge of the source, so that very short edges are less sharp. // edge of the source, so that very short edges are less sharp.
const yAdjustment = Math.min( const yAdjustment = Math.min(
Math.abs(inputs.targetOffset.x) - halfSourceSize.x + RADIUS_Y_ADJUSTMENT / 2.0, Math.abs(inputs.targetOffset.x) - halfSourceSize.x + innerTheme.radius_y_adjustment / 2.0,
RADIUS_Y_ADJUSTMENT, innerTheme.radius_y_adjustment,
) )
const radiusY = Math.max(Math.abs(inputs.targetOffset.y) - yAdjustment, 0.0) const radiusY = Math.max(Math.abs(inputs.targetOffset.y) - yAdjustment, 0.0)
const maxRadius = Math.min(radiusX, radiusY) const maxRadius = Math.min(radiusX, radiusY)
@ -222,7 +187,7 @@ function junctionPoints(inputs: Inputs): JunctionPoints | null {
Math.abs(inputs.targetOffset.y), Math.abs(inputs.targetOffset.y),
) )
let sourceDY = 0 let sourceDY = 0
if (naturalRadius > MINIMUM_TANGENT_EXIT_RADIUS) { if (naturalRadius > innerTheme.minimum_tangent_exit_radius) {
// Offset the beginning of the edge so that it is normal to the curve of the source node // Offset the beginning of the edge so that it is normal to the curve of the source node
// at the point that it exits the node. // at the point that it exits the node.
const radius = Math.min(naturalRadius, maxRadius) const radius = Math.min(naturalRadius, maxRadius)
@ -232,7 +197,7 @@ function junctionPoints(inputs: Inputs): JunctionPoints | null {
const intersection = circleIntersection(circleOffset, theme.node.corner_radius, radius) const intersection = circleIntersection(circleOffset, theme.node.corner_radius, radius)
sourceDY = -Math.abs(radius - intersection) sourceDY = -Math.abs(radius - intersection)
} else if (halfSourceSize.y != 0) { } else if (halfSourceSize.y != 0) {
sourceDY = -SOURCE_NODE_OVERLAP + halfSourceSize.y sourceDY = -innerTheme.source_node_overlap + halfSourceSize.y
} }
const source = new Vec2(sourceX, sourceDY) const source = new Vec2(sourceX, sourceDY)
// The target attachment will extend as far toward the edge of the node as it can without // The target attachment will extend as far toward the edge of the node as it can without
@ -249,7 +214,7 @@ function junctionPoints(inputs: Inputs): JunctionPoints | null {
targetAttachment: attachment, targetAttachment: attachment,
} }
} else { } else {
const { RADIUS_MAX } = ThreeCorner const radiusMax = theme.edge.three_corner.radius_max
// The edge originates from either side of the node. // The edge originates from either side of the node.
const signX = Math.sign(inputs.targetOffset.x) const signX = Math.sign(inputs.targetOffset.x)
const sourceX = Math.abs(sourceMaxXOffset) * signX const sourceX = Math.abs(sourceMaxXOffset) * signX
@ -265,11 +230,11 @@ function junctionPoints(inputs: Inputs): JunctionPoints | null {
// \ // \
// J0 // J0
// Junctions (J0, J1) are in between source and target. // Junctions (J0, J1) are in between source and target.
const j0Dx = Math.min(2 * RADIUS_MAX, distanceX / 2) const j0Dx = Math.min(2 * radiusMax, distanceX / 2)
const j1Dx = Math.min(RADIUS_MAX, (distanceX - j0Dx) / 2) const j1Dx = Math.min(radiusMax, (distanceX - j0Dx) / 2)
j0x = sourceX + Math.abs(j0Dx) * signX j0x = sourceX + Math.abs(j0Dx) * signX
j1x = j0x + Math.abs(j1Dx) * signX j1x = j0x + Math.abs(j1Dx) * signX
heightAdjustment = RADIUS_MAX - j1Dx heightAdjustment = radiusMax - j1Dx
} else { } else {
// J1 // J1
// / // /
@ -278,16 +243,16 @@ function junctionPoints(inputs: Inputs): JunctionPoints | null {
// //
// //
// J0 > source; J0 > J1; J1 > target. // J0 > source; J0 > J1; J1 > target.
j1x = inputs.targetOffset.x + Math.abs(RADIUS_MAX) * signX j1x = inputs.targetOffset.x + Math.abs(radiusMax) * signX
const j0BeyondSource = Math.abs(inputs.targetOffset.x) + RADIUS_MAX * 2 const j0BeyondSource = Math.abs(inputs.targetOffset.x) + radiusMax * 2
const j0BeyondTarget = Math.abs(sourceX) + RADIUS_MAX const j0BeyondTarget = Math.abs(sourceX) + radiusMax
j0x = Math.abs(Math.max(j0BeyondTarget, j0BeyondSource)) * signX j0x = Math.abs(Math.max(j0BeyondTarget, j0BeyondSource)) * signX
heightAdjustment = 0 heightAdjustment = 0
} }
if (j0x == null || j1x == null || heightAdjustment == null) return null if (j0x == null || j1x == null || heightAdjustment == null) return null
const attachmentHeight = inputs.targetPortTopDistanceInNode ?? 0 const attachmentHeight = inputs.targetPortTopDistanceInNode ?? 0
const top = Math.min( const top = Math.min(
inputs.targetOffset.y - MIN_APPROACH_HEIGHT - attachmentHeight + heightAdjustment, inputs.targetOffset.y - theme.edge.min_approach_height - attachmentHeight + heightAdjustment,
0, 0,
) )
const source = new Vec2(sourceX, 0) const source = new Vec2(sourceX, 0)
@ -297,7 +262,7 @@ function junctionPoints(inputs: Inputs): JunctionPoints | null {
const attachmentTarget = attachment?.target ?? inputs.targetOffset const attachmentTarget = attachment?.target ?? inputs.targetOffset
return { return {
points: [source, j0, j1, attachmentTarget], points: [source, j0, j1, attachmentTarget],
maxRadius: RADIUS_MAX, maxRadius: radiusMax,
targetAttachment: attachment, targetAttachment: attachment,
} }
} }
@ -440,12 +405,12 @@ const activeStyle = computed(() => {
const baseStyle = computed(() => ({ '--node-base-color': edgeColor.value ?? 'tan' })) const baseStyle = computed(() => ({ '--node-base-color': edgeColor.value ?? 'tan' }))
function click(_e: PointerEvent) { function click() {
if (base.value == null) return {} if (base.value == null) return
if (navigator?.sceneMousePos == null) return {} if (navigator?.sceneMousePos == null) return
const length = base.value.getTotalLength() const length = base.value.getTotalLength()
let offset = lengthTo(navigator?.sceneMousePos) let offset = lengthTo(navigator?.sceneMousePos)
if (offset == null) return {} if (offset == null) return
if (offset < length / 2) graph.disconnectTarget(props.edge) if (offset < length / 2) graph.disconnectTarget(props.edge)
else graph.disconnectSource(props.edge) else graph.disconnectSource(props.edge)
} }
@ -457,7 +422,7 @@ function arrowPosition(): Vec2 | undefined {
const target = targetRect.value const target = targetRect.value
const source = sourceRect.value const source = sourceRect.value
if (target == null || source == null) return if (target == null || source == null) return
if (target.pos.y > source.pos.y - ThreeCorner.BACKWARD_EDGE_ARROW_THRESHOLD) return if (target.pos.y > source.pos.y - theme.edge.three_corner.backward_edge_arrow_threshold) return
if (points[1] == null) return if (points[1] == null) return
return source.center().add(points[1]) return source.center().add(points[1])
} }

View File

@ -1,4 +1,4 @@
<script setup lang="ts"> <script lang="ts">
import { nodeEditBindings } from '@/bindings' import { nodeEditBindings } from '@/bindings'
import CircularMenu from '@/components/CircularMenu.vue' import CircularMenu from '@/components/CircularMenu.vue'
import GraphNodeError from '@/components/GraphEditor/GraphNodeError.vue' import GraphNodeError from '@/components/GraphEditor/GraphNodeError.vue'
@ -12,12 +12,14 @@ import { injectGraphNavigator } from '@/providers/graphNavigator'
import { injectGraphSelection } from '@/providers/graphSelection' import { injectGraphSelection } from '@/providers/graphSelection'
import { useGraphStore, type Node } from '@/stores/graph' import { useGraphStore, type Node } from '@/stores/graph'
import { useProjectStore } from '@/stores/project' import { useProjectStore } from '@/stores/project'
import { Ast } from '@/util/ast'
import { Prefixes } from '@/util/ast/prefixes'
import type { Opt } from '@/util/data/opt' import type { Opt } from '@/util/data/opt'
import { Rect } from '@/util/data/rect' import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2' import { Vec2 } from '@/util/data/vec2'
import { displayedIconOf } from '@/util/getIconName' import { displayedIconOf } from '@/util/getIconName'
import { setIfUndefined } from 'lib0/map' import { setIfUndefined } from 'lib0/map'
import type { ContentRange, ExprId, VisualizationIdentifier } from 'shared/yjsModel' import { type ExprId, type VisualizationIdentifier } from 'shared/yjsModel'
import { computed, ref, watch, watchEffect } from 'vue' import { computed, ref, watch, watchEffect } from 'vue'
const MAXIMUM_CLICK_LENGTH_MS = 300 const MAXIMUM_CLICK_LENGTH_MS = 300
@ -25,6 +27,18 @@ const MAXIMUM_CLICK_DISTANCE_SQ = 50
/** The width in pixels that is not the widget tree. This includes the icon, and padding. */ /** The width in pixels that is not the widget tree. This includes the icon, and padding. */
const NODE_EXTRA_WIDTH_PX = 30 const NODE_EXTRA_WIDTH_PX = 30
const prefixes = Prefixes.FromLines({
enableOutputContext:
'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output __ <| __',
disableOutputContext:
'Standard.Base.Runtime.with_disabled_context Standard.Base.Runtime.Context.Output __ <| __',
// Currently unused; included as PoC.
skip: 'SKIP __',
freeze: 'FREEZE __',
})
</script>
<script setup lang="ts">
const props = defineProps<{ const props = defineProps<{
node: Node node: Node
edited: boolean edited: boolean
@ -39,10 +53,8 @@ const emit = defineEmits<{
outputPortClick: [portId: ExprId] outputPortClick: [portId: ExprId]
outputPortDoubleClick: [portId: ExprId] outputPortDoubleClick: [portId: ExprId]
doubleClick: [] doubleClick: []
'update:content': [updates: [range: ContentRange, content: string][]]
'update:edited': [cursorPosition: number] 'update:edited': [cursorPosition: number]
'update:rect': [rect: Rect] 'update:rect': [rect: Rect]
'update:selected': [selected: boolean]
'update:visualizationId': [id: Opt<VisualizationIdentifier>] 'update:visualizationId': [id: Opt<VisualizationIdentifier>]
'update:visualizationRect': [rect: Rect | undefined] 'update:visualizationRect': [rect: Rect | undefined]
'update:visualizationVisible': [visible: boolean] 'update:visualizationVisible': [visible: boolean]
@ -89,7 +101,6 @@ watch(isSelected, (selected) => {
menuVisible.value = menuVisible.value && selected menuVisible.value = menuVisible.value && selected
}) })
const isAutoEvaluationDisabled = ref(false)
const isDocsVisible = ref(false) const isDocsVisible = ref(false)
const isVisualizationVisible = computed(() => props.node.vis?.visible ?? false) const isVisualizationVisible = computed(() => props.node.vis?.visible ?? false)
@ -145,6 +156,51 @@ const dragPointer = usePointer((pos, event, type) => {
} }
}) })
const matches = computed(() => prefixes.extractMatches(props.node.rootSpan))
const displayedExpression = computed(() => matches.value.innerExpr)
const isOutputContextOverridden = computed({
get() {
const override =
matches.value.matches.enableOutputContext ?? matches.value.matches.disableOutputContext
const overrideEnabled = matches.value.matches.enableOutputContext != null
// An override is only counted as enabled if it is currently in effect. This requires:
// - that an override exists
if (!override) return false
// - that it is setting the "enabled" value to a non-default value
else if (overrideEnabled === projectStore.isOutputContextEnabled) return false
// - and that it applies to the current execution context.
else {
const contextWithoutQuotes = override[0]?.code().replace(/^['"]|['"]$/g, '')
return contextWithoutQuotes === projectStore.executionMode
}
},
set(shouldOverride) {
const module = projectStore.module
if (!module) return
const replacements = shouldOverride
? [Ast.TextLiteral.new(projectStore.executionMode)]
: undefined
const edit = props.node.rootSpan.module.edit()
const newAst = prefixes.modify(
edit,
props.node.rootSpan,
projectStore.isOutputContextEnabled
? {
enableOutputContext: undefined,
disableOutputContext: replacements,
}
: {
enableOutputContext: replacements,
disableOutputContext: undefined,
},
)
graph.setNodeContent(props.node.rootSpan.astId, newAst.code())
},
})
// FIXME [sb]: https://github.com/enso-org/enso/issues/8442
// This does not take into account `displayedExpression`.
const expressionInfo = computed(() => graph.db.getExpressionInfo(nodeId.value)) const expressionInfo = computed(() => graph.db.getExpressionInfo(nodeId.value))
const outputPortLabel = computed(() => expressionInfo.value?.typename ?? 'Unknown') const outputPortLabel = computed(() => expressionInfo.value?.typename ?? 'Unknown')
const executionState = computed(() => expressionInfo.value?.payload.type ?? 'Unknown') const executionState = computed(() => expressionInfo.value?.payload.type ?? 'Unknown')
@ -223,6 +279,7 @@ const handleNodeClick = useDoubleClick(
(e: MouseEvent) => nodeEditHandler(e), (e: MouseEvent) => nodeEditHandler(e),
() => emit('doubleClick'), () => emit('doubleClick'),
).handleClick ).handleClick
interface PortData { interface PortData {
clipRange: [number, number] clipRange: [number, number]
label: string label: string
@ -232,7 +289,7 @@ interface PortData {
const outputPorts = computed((): PortData[] => { const outputPorts = computed((): PortData[] => {
const ports = outputPortsSet.value const ports = outputPortsSet.value
const numPorts = ports.size const numPorts = ports.size
return Array.from(ports, (portId, index) => { return Array.from(ports, (portId, index): PortData => {
const labelIdent = numPorts > 1 ? graph.db.getOutputPortIdentifier(portId) + ': ' : '' const labelIdent = numPorts > 1 ? graph.db.getOutputPortIdentifier(portId) + ': ' : ''
const labelType = const labelType =
graph.db.getExpressionInfo(numPorts > 1 ? portId : nodeId.value)?.typename ?? 'Unknown' graph.db.getExpressionInfo(numPorts > 1 ? portId : nodeId.value)?.typename ?? 'Unknown'
@ -296,8 +353,9 @@ function portGroupStyle(port: PortData) {
</div> </div>
<CircularMenu <CircularMenu
v-if="menuVisible" v-if="menuVisible"
v-model:isAutoEvaluationDisabled="isAutoEvaluationDisabled" v-model:isOutputContextOverridden="isOutputContextOverridden"
v-model:isDocsVisible="isDocsVisible" v-model:isDocsVisible="isDocsVisible"
:isOutputContextEnabledGlobally="projectStore.isOutputContextEnabled"
:isVisualizationVisible="isVisualizationVisible" :isVisualizationVisible="isVisualizationVisible"
@update:isVisualizationVisible="emit('update:visualizationVisible', $event)" @update:isVisualizationVisible="emit('update:visualizationVisible', $event)"
/> />
@ -320,7 +378,7 @@ function portGroupStyle(port: PortData) {
<div class="node" @pointerdown="handleNodeClick" v-on="dragPointer.events"> <div class="node" @pointerdown="handleNodeClick" v-on="dragPointer.events">
<SvgIcon class="icon grab-handle" :name="icon"></SvgIcon> <SvgIcon class="icon grab-handle" :name="icon"></SvgIcon>
<div ref="contentNode" class="widget-tree"> <div ref="contentNode" class="widget-tree">
<NodeWidgetTree :ast="node.rootSpan" /> <NodeWidgetTree :ast="displayedExpression" />
</div> </div>
</div> </div>
<GraphNodeError v-if="error" class="error" :error="error" /> <GraphNodeError v-if="error" class="error" :error="error" />

View File

@ -9,7 +9,7 @@ import { useGraphStore } from '@/stores/graph'
import { useProjectStore } from '@/stores/project' import { useProjectStore } from '@/stores/project'
import type { Vec2 } from '@/util/data/vec2' import type { Vec2 } from '@/util/data/vec2'
import { stackItemsEqual } from 'shared/languageServerTypes' import { stackItemsEqual } from 'shared/languageServerTypes'
import type { ContentRange, ExprId } from 'shared/yjsModel' import type { ExprId } from 'shared/yjsModel'
import { computed, toRaw } from 'vue' import { computed, toRaw } from 'vue'
const projectStore = useProjectStore() const projectStore = useProjectStore()
@ -23,14 +23,6 @@ const emit = defineEmits<{
nodeDoubleClick: [nodeId: ExprId] nodeDoubleClick: [nodeId: ExprId]
}>() }>()
function updateNodeContent(id: ExprId, updates: [ContentRange, string][]) {
graphStore.transact(() => {
for (const [range, content] of updates) {
graphStore.replaceNodeSubexpression(id, range, content)
}
})
}
function nodeIsDragged(movedId: ExprId, offset: Vec2) { function nodeIsDragged(movedId: ExprId, offset: Vec2) {
const scaledOffset = offset.scale(1 / (navigator?.scale ?? 1)) const scaledOffset = offset.scale(1 / (navigator?.scale ?? 1))
dragging.startOrUpdate(movedId, scaledOffset) dragging.startOrUpdate(movedId, scaledOffset)
@ -42,7 +34,7 @@ function hoverNode(id: ExprId | undefined) {
const uploadingFiles = computed<[FileName, File][]>(() => { const uploadingFiles = computed<[FileName, File][]>(() => {
const currentStackItem = projectStore.executionContext.getStackTop() const currentStackItem = projectStore.executionContext.getStackTop()
return [...projectStore.awareness.allUploads()].filter(([_name, file]) => return [...projectStore.awareness.allUploads()].filter(([, file]) =>
stackItemsEqual(file.stackItem, toRaw(currentStackItem)), stackItemsEqual(file.stackItem, toRaw(currentStackItem)),
) )
}) })
@ -54,15 +46,13 @@ const uploadingFiles = computed<[FileName, File][]>(() => {
:key="id" :key="id"
:node="node" :node="node"
:edited="id === graphStore.editedNodeInfo?.id" :edited="id === graphStore.editedNodeInfo?.id"
@delete="graphStore.deleteNode(id)"
@pointerenter="hoverNode(id)" @pointerenter="hoverNode(id)"
@pointerleave="hoverNode(undefined)" @pointerleave="hoverNode(undefined)"
@dragging="nodeIsDragged(id, $event)" @dragging="nodeIsDragged(id, $event)"
@draggingCommited="dragging.finishDrag()" @draggingCommited="dragging.finishDrag()"
@outputPortClick="graphStore.createEdgeFromOutput" @outputPortClick="graphStore.createEdgeFromOutput($event)"
@outputPortDoubleClick="emit('nodeOutputPortDoubleClick', $event)" @outputPortDoubleClick="emit('nodeOutputPortDoubleClick', $event)"
@doubleClick="emit('nodeDoubleClick', id)" @doubleClick="emit('nodeDoubleClick', id)"
@update:content="updateNodeContent(id, $event)"
@update:edited="graphStore.setEditedNode(id, $event)" @update:edited="graphStore.setEditedNode(id, $event)"
@update:rect="graphStore.updateNodeRect(id, $event)" @update:rect="graphStore.updateNodeRect(id, $event)"
@update:visualizationId="graphStore.setNodeVisualizationId(id, $event)" @update:visualizationId="graphStore.setNodeVisualizationId(id, $event)"

View File

@ -20,7 +20,7 @@ const value = computed({
}, },
set(value) { set(value) {
const newCode = `[${value.map((item) => item.code()).join(', ')}]` const newCode = `[${value.map((item) => item.code()).join(', ')}]`
graph.replaceExpressionContent(props.input.astId, newCode) graph.setExpressionContent(props.input.astId, newCode)
}, },
}) })

View File

@ -14,7 +14,7 @@ const props = withDefaults(defineProps<{ icon: Icon; modelValue?: boolean }>(),
modelValue: false, modelValue: false,
}) })
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:modelValue', toggledOn: boolean): void 'update:modelValue': [toggledOn: boolean]
}>() }>()
</script> </script>

View File

@ -273,7 +273,8 @@ export function usePointer(
trackedPointer.value = e.pointerId trackedPointer.value = e.pointerId
// This is mostly SAFE, as virtually all `Element`s also extend `GlobalEventHandlers`. // This is mostly SAFE, as virtually all `Element`s also extend `GlobalEventHandlers`.
trackedElement = e.currentTarget as Element & GlobalEventHandlers trackedElement = e.currentTarget as Element & GlobalEventHandlers
trackedElement.setPointerCapture(e.pointerId) // `setPointerCapture` is not defined in tests.
trackedElement.setPointerCapture?.(e.pointerId)
initialGrabPos = new Vec2(e.clientX, e.clientY) initialGrabPos = new Vec2(e.clientX, e.clientY)
lastPos = initialGrabPos lastPos = initialGrabPos
handler(computePosition(e, initialGrabPos, lastPos), e, 'start') handler(computePosition(e, initialGrabPos, lastPos), e, 'start')

View File

@ -3,12 +3,14 @@ import { SuggestionDb, groupColorStyle, type Group } from '@/stores/suggestionDa
import type { SuggestionEntry } from '@/stores/suggestionDatabase/entry' import type { SuggestionEntry } from '@/stores/suggestionDatabase/entry'
import { Ast, RawAst, RawAstExtended } from '@/util/ast' import { Ast, RawAst, RawAstExtended } from '@/util/ast'
import { AliasAnalyzer } from '@/util/ast/aliasAnalysis' import { AliasAnalyzer } from '@/util/ast/aliasAnalysis'
import { nodeFromAst } from '@/util/ast/node'
import { colorFromString } from '@/util/colors' import { colorFromString } from '@/util/colors'
import { MappedKeyMap, MappedSet } from '@/util/containers' import { MappedKeyMap, MappedSet } from '@/util/containers'
import { arrayEquals, byteArraysEqual, tryGetIndex } from '@/util/data/array' import { arrayEquals, byteArraysEqual, tryGetIndex } from '@/util/data/array'
import type { Opt } from '@/util/data/opt' import type { Opt } from '@/util/data/opt'
import { Vec2 } from '@/util/data/vec2' import { Vec2 } from '@/util/data/vec2'
import { ReactiveDb, ReactiveIndex, ReactiveMapping } from '@/util/database/reactiveDb' import { ReactiveDb, ReactiveIndex, ReactiveMapping } from '@/util/database/reactiveDb'
import * as random from 'lib0/random'
import * as set from 'lib0/set' import * as set from 'lib0/set'
import { methodPointerEquals, type MethodCall } from 'shared/languageServerTypes' import { methodPointerEquals, type MethodCall } from 'shared/languageServerTypes'
import { import {
@ -310,17 +312,19 @@ export class GraphDb {
return new GraphDb(db, ref([]), registry) return new GraphDb(db, ref([]), registry)
} }
mockNode(binding: string, id: ExprId, code?: string) { mockNode(binding: string, id: ExprId, code?: string): Node {
const node = { const pattern = Ast.parse(binding)
const node: Node = {
outerExprId: id, outerExprId: id,
pattern: Ast.parse(binding), pattern,
rootSpan: Ast.parse(code ?? '0'), rootSpan: Ast.parse(code ?? '0'),
position: Vec2.Zero, position: Vec2.Zero,
vis: undefined, vis: undefined,
} }
const bidingId = node.pattern.astId const bindingId = pattern.astId
this.nodeIdToNode.set(id, node) this.nodeIdToNode.set(id, node)
this.bindings.bindings.set(bidingId, { identifier: binding, usages: new Set() }) this.bindings.bindings.set(bindingId, { identifier: binding, usages: new Set() })
return node
} }
} }
@ -332,25 +336,16 @@ export interface Node {
vis: Opt<VisualizationMetadata> vis: Opt<VisualizationMetadata>
} }
function nodeFromAst(ast: Ast.Ast): Node { /** This should only be used for supplying as initial props when testing.
const common = { * Please do {@link GraphDb.mockNode} with a `useGraphStore().db` after mount. */
outerExprId: ast.exprId, export function mockNode(exprId?: ExprId): Node {
return {
outerExprId: exprId ?? (random.uuidv4() as ExprId),
pattern: undefined,
rootSpan: Ast.parse('0'),
position: Vec2.Zero, position: Vec2.Zero,
vis: undefined, vis: undefined,
} }
if (ast instanceof Ast.Assignment && ast.expression) {
return {
...common,
pattern: ast.pattern ?? undefined,
rootSpan: ast.expression,
}
} else {
return {
...common,
pattern: undefined,
rootSpan: ast,
}
}
} }
function mathodCallEquals(a: MethodCall | undefined, b: MethodCall | undefined): boolean { function mathodCallEquals(a: MethodCall | undefined, b: MethodCall | undefined): boolean {

View File

@ -156,13 +156,13 @@ export const useGraphStore = defineStore('graph', () => {
} }
function disconnectSource(edge: Edge) { function disconnectSource(edge: Edge) {
if (edge.target) if (!edge.target) return
unconnectedEdge.value = { target: edge.target, disconnectedEdgeTarget: edge.target } unconnectedEdge.value = { target: edge.target, disconnectedEdgeTarget: edge.target }
} }
function disconnectTarget(edge: Edge) { function disconnectTarget(edge: Edge) {
if (edge.source && edge.target) if (!edge.source || !edge.target) return
unconnectedEdge.value = { source: edge.source, disconnectedEdgeTarget: edge.target } unconnectedEdge.value = { source: edge.source, disconnectedEdgeTarget: edge.target }
} }
function clearUnconnected() { function clearUnconnected() {
@ -230,20 +230,6 @@ export const useGraphStore = defineStore('graph', () => {
proj.stopCapturingUndo() proj.stopCapturingUndo()
} }
function replaceNodeSubexpression(
nodeId: ExprId,
range: ContentRange | undefined,
content: string,
) {
const node = db.nodeIdToNode.get(nodeId)
if (!node) return
proj.module?.replaceExpressionContent(node.rootSpan.astId, content, range)
}
function replaceExpressionContent(exprId: ExprId, content: string) {
proj.module?.replaceExpressionContent(exprId, content)
}
function setNodePosition(nodeId: ExprId, position: Vec2) { function setNodePosition(nodeId: ExprId, position: Vec2) {
const node = db.nodeIdToNode.get(nodeId) const node = db.nodeIdToNode.get(nodeId)
if (!node) return if (!node) return
@ -338,8 +324,6 @@ export const useGraphStore = defineStore('graph', () => {
deleteNode, deleteNode,
setNodeContent, setNodeContent,
setExpressionContent, setExpressionContent,
replaceNodeSubexpression,
replaceExpressionContent,
setNodePosition, setNodePosition,
setNodeVisualizationId, setNodeVisualizationId,
setNodeVisualizationVisible, setNodeVisualizationVisible,

View File

@ -85,6 +85,9 @@ async function initializeLsRpcConnection(
error, error,
) )
}, },
}).catch((error) => {
console.error('Error initializing Language Server RPC:', error)
throw error
}) })
const contentRoots = initialization.contentRoots const contentRoots = initialization.contentRoots
return { connection, contentRoots } return { connection, contentRoots }
@ -93,7 +96,10 @@ async function initializeLsRpcConnection(
async function initializeDataConnection(clientId: Uuid, url: string) { async function initializeDataConnection(clientId: Uuid, url: string) {
const client = createWebsocketClient(url, { binaryType: 'arraybuffer', sendPings: false }) const client = createWebsocketClient(url, { binaryType: 'arraybuffer', sendPings: false })
const connection = new DataServer(client) const connection = new DataServer(client)
await connection.initialize(clientId) await connection.initialize(clientId).catch((error) => {
console.error('Error initializing data connection:', error)
throw error
})
return connection return connection
} }
@ -439,8 +445,20 @@ export const useProjectStore = defineStore('project', () => {
const clientId = random.uuidv4() as Uuid const clientId = random.uuidv4() as Uuid
const lsUrls = resolveLsUrl(config.value) const lsUrls = resolveLsUrl(config.value)
const initializedConnection = initializeLsRpcConnection(clientId, lsUrls.rpcUrl) const initializedConnection = initializeLsRpcConnection(clientId, lsUrls.rpcUrl)
const lsRpcConnection = initializedConnection.then(({ connection }) => connection) const lsRpcConnection = initializedConnection.then(
const contentRoots = initializedConnection.then(({ contentRoots }) => contentRoots) ({ connection }) => connection,
(error) => {
console.error('Error getting Language Server connection:', error)
throw error
},
)
const contentRoots = initializedConnection.then(
({ contentRoots }) => contentRoots,
(error) => {
console.error('Error getting content roots:', error)
throw error
},
)
const dataConnection = initializeDataConnection(clientId, lsUrls.dataUrl) const dataConnection = initializeDataConnection(clientId, lsUrls.dataUrl)
const rpcUrl = new URL(lsUrls.rpcUrl) const rpcUrl = new URL(lsUrls.rpcUrl)
@ -514,7 +532,7 @@ export const useProjectStore = defineStore('project', () => {
moduleDocGuid.value = guid moduleDocGuid.value = guid
} }
projectModel.modules.observe((_) => tryReadDocGuid()) projectModel.modules.observe(tryReadDocGuid)
watchEffect(tryReadDocGuid) watchEffect(tryReadDocGuid)
const module = computedAsync(async () => { const module = computedAsync(async () => {
@ -537,8 +555,16 @@ export const useProjectStore = defineStore('project', () => {
}) })
} }
const firstExecution = lsRpcConnection.then((lsRpc) => const firstExecution = lsRpcConnection.then(
nextEvent(lsRpc, 'executionContext/executionComplete'), (lsRpc) =>
nextEvent(lsRpc, 'executionContext/executionComplete').catch((error) => {
console.error('First execution failed:', error)
throw error
}),
(error) => {
console.error('Could not get Language Server for first execution:', error)
throw error
},
) )
const executionContext = createExecutionContextForMain() const executionContext = createExecutionContextForMain()
const visualizationDataRegistry = new VisualizationDataRegistry(executionContext, dataConnection) const visualizationDataRegistry = new VisualizationDataRegistry(executionContext, dataConnection)
@ -603,6 +629,8 @@ export const useProjectStore = defineStore('project', () => {
}) })
}) })
const isOutputContextEnabled = computed(() => executionMode.value === 'live')
function stopCapturingUndo() { function stopCapturingUndo() {
module.value?.undoManager.stopCapturing() module.value?.undoManager.stopCapturing()
} }
@ -661,6 +689,7 @@ export const useProjectStore = defineStore('project', () => {
lsRpcConnection: markRaw(lsRpcConnection), lsRpcConnection: markRaw(lsRpcConnection),
dataConnection: markRaw(dataConnection), dataConnection: markRaw(dataConnection),
useVisualizationData, useVisualizationData,
isOutputContextEnabled,
stopCapturingUndo, stopCapturingUndo,
executionMode, executionMode,
dataflowErrors, dataflowErrors,

View File

@ -367,7 +367,7 @@ const parseCases = [
] ]
test.each(parseCases)('parse: %s', (testCase) => { test.each(parseCases)('parse: %s', (testCase) => {
const root = Ast.parse(testCase.code) const root = Ast.parse(testCase.code)
expect(Ast.debug(root)).toEqual(testCase.tree) expect(Ast.tokenTree(root)).toEqual(testCase.tree)
}) })
// TODO: Edits (#8367). // TODO: Edits (#8367).

View File

@ -0,0 +1,109 @@
import { Ast } from '@/util/ast'
import { Pattern } from '@/util/ast/match'
import { expect, test } from 'vitest'
import { MutableModule } from '../abstract'
test.each([
{ target: 'a.b', pattern: '__', extracted: ['a.b'] },
{ target: 'a.b', pattern: 'a.__', extracted: ['b'] },
{ target: 'a.b', pattern: '__.b', extracted: ['a'] },
{ target: '1 + 1', pattern: '1 + 1', extracted: [] },
{ target: '1 + 2', pattern: '1 + __', extracted: ['2'] },
{ target: '1 + 2', pattern: '__ + 2', extracted: ['1'] },
{ target: '1 + 2', pattern: '__ + __', extracted: ['1', '2'] },
{ target: '1', pattern: '__', extracted: ['1'] },
{ target: '1', pattern: '(__)' },
{ target: '("a")', pattern: '(__)', extracted: ['"a"'] },
{ target: '[1, "a", True]', pattern: '[1, "a", True]', extracted: [] },
{ target: '[1, "a", True]', pattern: '[__, "a", __]', extracted: ['1', 'True'] },
{ target: '[1, "a", True]', pattern: '[1, "a", False]' },
{ target: '[1, "a", True]', pattern: '[1, "a", True, 2]' },
{ target: '[1, "a", True]', pattern: '[1, "a", True, __]' },
{ target: '(1)', pattern: '1' },
{ target: '(1)', pattern: '__', extracted: ['(1)'] }, // True because `__` matches any expression.
{ target: '1 + 1', pattern: '1 + 2' },
{ target: '1 + 1', pattern: '"1" + 1' },
{ target: '1 + 1', pattern: '1 + "1"' },
{ target: '1 + 1 + 1', pattern: '(1 + 1) + 1' },
{ target: '1 + 1 + 1', pattern: '1 + (1 + 1)' },
{ target: '(1 + 1) + 1', pattern: '1 + 1 + 1' },
{ target: '1 + (1 + 1)', pattern: '1 + 1 + 1' },
{
target:
'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output "current_context_name" <| node1.fn',
pattern:
'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output "current_context_name" <| __',
extracted: ['node1.fn'],
},
{
target:
'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output "current_context_name" <| node1.fn',
pattern:
'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output __ <| __',
extracted: ['"current_context_name"', 'node1.fn'],
},
{
target:
'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output "current_context_name" <| node1.fn',
pattern: 'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output __',
},
{
target:
'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output "current_context_name" <| node1.fn',
pattern:
'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output "current_context#name" <| node1.fn',
},
{
target:
'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output "current_context_name" <| node1.fn',
pattern:
'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output "current_context_name" <| node2.fn',
},
{
target:
'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output "current_context_name" <| a + b',
pattern:
'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output __ <| __',
extracted: ['"current_context_name"', 'a + b'],
},
{
target:
"Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output 'current_context_name' <| a + b",
pattern:
'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output __ <| __',
extracted: ["'current_context_name'", 'a + b'],
},
{
target:
"Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output 'current_context_name' <| a + b",
pattern: 'Standard.Base.Runtime.__ Standard.Base.Runtime.Context.Output __ <| __',
extracted: ['with_enabled_context', "'current_context_name'", 'a + b'],
},
])('`isMatch` and `extractMatches`', ({ target, pattern, extracted }) => {
const targetAst = Ast.parseLine(target)
const module = targetAst.module
const patternAst = Pattern.parse(pattern)
expect(
patternAst.match(targetAst) !== undefined,
`'${target}' has CST ${extracted != null ? '' : 'not '}matching '${pattern}'`,
).toBe(extracted != null)
expect(
patternAst.match(targetAst)?.map((match) => module.get(match)?.code()),
extracted != null
? `'${target}' matches '${pattern}' with '__'s corresponding to ${JSON.stringify(extracted)
.slice(1, -1)
.replace(/"/g, "'")}`
: `'${target}' does not match '${pattern}'`,
).toStrictEqual(extracted)
})
test.each([
{ template: 'a __ c', source: 'b', result: 'a b c' },
{ template: 'a . __ . c', source: 'b', result: 'a . b . c' },
])('instantiate', ({ template, source, result }) => {
const pattern = Pattern.parse(template)
const edit = MutableModule.Transient()
const intron = Ast.parse(source, edit)
const instantiated = pattern.instantiate(edit, [intron.exprId])
expect(instantiated.code(edit)).toBe(result)
})

View File

@ -0,0 +1,79 @@
import { Ast } from '@/util/ast/abstract'
import { Prefixes } from '@/util/ast/prefixes'
import { expect, test } from 'vitest'
test.each([
{
prefixes: {
a: 'a + __',
},
modifications: {
a: [],
},
source: 'b',
target: 'a + b',
},
{
prefixes: {
a: 'a + __ + c',
},
modifications: {
a: [],
},
source: 'd',
target: 'a + d + c',
},
{
prefixes: {
a: '__+e + __',
},
modifications: {
a: ['d'],
},
source: 'f',
target: 'd+e + f',
},
{
prefixes: {
enable:
'Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output __ <| __',
},
modifications: {
enable: ["'foo'"],
},
source: 'a + b',
target:
"Standard.Base.Runtime.with_enabled_context Standard.Base.Runtime.Context.Output 'foo' <| a + b",
},
{
prefixes: {
a: '__+e + __',
},
modifications: {
a: undefined,
},
source: 'd+e + f',
target: 'f',
},
{
prefixes: {
a: '__+e + __',
},
modifications: {
a: undefined,
},
source: 'd + e + f',
target: 'f',
},
])('modify', ({ prefixes: lines, modifications, source, target }) => {
const prefixes = Prefixes.FromLines(lines as any)
const sourceAst = Ast.parseLine(source)
const edit = sourceAst.module.edit()
const modificationAsts = Object.fromEntries(
Object.entries(modifications).map(([k, v]) => [
k,
v ? Array.from(v, (mod) => Ast.parse(mod, edit)) : undefined,
]),
)
expect(prefixes.modify(edit, sourceAst, modificationAsts).code(edit)).toBe(target)
})

View File

@ -0,0 +1,10 @@
import * as astText from '@/util/ast/text'
import { expect, test } from 'vitest'
test.each([
{ string: 'abcdef_123', escaped: 'abcdef_123' },
{ string: '\t\r\n\v"\'`', escaped: '\\t\\r\\n\\v\\"\\\'``' },
{ string: '`foo` `bar` `baz`', escaped: '``foo`` ``bar`` ``baz``' },
])('`escape`', ({ string, escaped }) => {
expect(astText.escape(string)).toBe(escaped)
})

View File

@ -1,77 +1,92 @@
import * as RawAst from '@/generated/ast' import * as RawAst from '@/generated/ast'
import { parseEnso } from '@/util/ast' import { parseEnso } from '@/util/ast'
import { AstExtended as RawAstExtended } from '@/util/ast/extended' import { AstExtended as RawAstExtended } from '@/util/ast/extended'
import type { Opt } from '@/util/data/opt'
import { Err, Ok, type Result } from '@/util/data/result' import { Err, Ok, type Result } from '@/util/data/result'
import type { LazyObject } from '@/util/parserSupport' import type { LazyObject } from '@/util/parserSupport'
import { unsafeEntries } from '@/util/record'
import * as random from 'lib0/random' import * as random from 'lib0/random'
import { IdMap, type ExprId } from 'shared/yjsModel' import { IdMap, type ExprId } from 'shared/yjsModel'
import { reactive } from 'vue' import { reactive } from 'vue'
interface Module { export interface Module {
get raw(): MutableModule
get(id: AstId): Ast | null get(id: AstId): Ast | null
getExtended(id: AstId): RawAstExtended | undefined getExtended(id: AstId): RawAstExtended | undefined
edit(): MutableModule
apply(module: MutableModule): void
} }
class Committed implements Module { export class MutableModule implements Module {
nodes: Map<AstId, Ast> base: Module | null
astExtended: Map<AstId, RawAstExtended> nodes: Map<AstId, Ast | null>
astExtended: Map<AstId, RawAstExtended> | null
constructor() { constructor(
this.nodes = reactive(new Map<AstId, Ast>()) base: Module | null,
this.astExtended = reactive(new Map<AstId, RawAstExtended>()) nodes: Map<AstId, Ast | null>,
} astExtended: Map<AstId, RawAstExtended> | null,
) {
/** Returns a syntax node representing the current committed state of the given ID. */
get(id: AstId): Ast | null {
return this.nodes.get(id) ?? null
}
getExtended(id: AstId): RawAstExtended | undefined {
return this.astExtended.get(id)
}
}
class Edit implements Module {
base: Committed
pending: Map<AstId, Ast | null>
constructor(base: Committed) {
this.base = base this.base = base
this.pending = new Map() this.nodes = nodes
this.astExtended = astExtended
} }
/** Replace all committed values with the state of the uncommitted parse. */ static Observable(): MutableModule {
commit() { const nodes = reactive(new Map<AstId, Ast>())
for (const [id, ast] of this.pending.entries()) { const astExtended = reactive(new Map<AstId, RawAstExtended>())
return new MutableModule(null, nodes, astExtended)
}
static Transient(): MutableModule {
const nodes = new Map<AstId, Ast>()
return new MutableModule(null, nodes, null)
}
edit(): MutableModule {
const nodes = new Map<AstId, Ast>()
return new MutableModule(this, nodes, null)
}
get raw(): MutableModule {
return this
}
apply(edit: MutableModule) {
for (const [id, ast] of edit.nodes.entries()) {
if (ast === null) { if (ast === null) {
this.base.nodes.delete(id) this.nodes.delete(id)
} else { } else {
this.base.nodes.set(id, ast) this.nodes.set(id, ast)
} }
} }
this.pending.clear() if (edit.astExtended) {
if (this.astExtended)
console.error(`Merging astExtended not implemented, probably doesn't make sense`)
this.astExtended = edit.astExtended
}
} }
/** Returns a syntax node representing the current committed state of the given ID. */ /** Returns a syntax node representing the current committed state of the given ID. */
get(id: AstId): Ast | null { get(id: AstId): Ast | null {
const editedNode = this.pending.get(id) const editedNode = this.nodes.get(id)
if (editedNode === null) { if (editedNode === null) {
return null return null
} else { } else {
return editedNode ?? this.base.get(id) ?? null return editedNode ?? this.base?.get(id) ?? null
} }
} }
set(id: AstId, ast: Ast) { set(id: AstId, ast: Ast) {
this.pending.set(id, ast) this.nodes.set(id, ast)
} }
getExtended(id: AstId): RawAstExtended | undefined { getExtended(id: AstId): RawAstExtended | undefined {
return this.base.astExtended.get(id) return this.astExtended?.get(id) ?? this.base?.getExtended(id)
} }
delete(id: AstId) { delete(id: AstId) {
this.pending.set(id, null) this.nodes.set(id, null)
} }
} }
@ -102,13 +117,17 @@ function newTokenId(): TokenId {
} }
export class Token { export class Token {
private _code: string code_: string
exprId: TokenId exprId: TokenId
readonly _tokenType: RawAst.Token.Type tokenType_: RawAst.Token.Type | undefined
constructor(code: string, id: TokenId, type: RawAst.Token.Type) { constructor(code: string, id: TokenId, type: RawAst.Token.Type | undefined) {
this._code = code this.code_ = code
this.exprId = id this.exprId = id
this._tokenType = type this.tokenType_ = type
}
static new(code: string) {
return new Token(code, newTokenId(), undefined)
} }
// Compatibility wrapper for `exprId`. // Compatibility wrapper for `exprId`.
@ -117,18 +136,19 @@ export class Token {
} }
code(): string { code(): string {
return this._code return this.code_
} }
typeName(): string { typeName(): string {
return RawAst.Token.typeNames[this._tokenType]! if (this.tokenType_) return RawAst.Token.typeNames[this.tokenType_]!
else return 'Raw'
} }
} }
export abstract class Ast { export abstract class Ast {
readonly treeType: RawAst.Tree.Type | undefined readonly treeType: RawAst.Tree.Type | undefined
_id: AstId _id: AstId
readonly module: Committed readonly module: Module
// Deprecated interface for incremental integration of Ast API. Eliminate usages for #8367. // Deprecated interface for incremental integration of Ast API. Eliminate usages for #8367.
get astExtended(): RawAstExtended | undefined { get astExtended(): RawAstExtended | undefined {
@ -140,16 +160,12 @@ export abstract class Ast {
} }
static deserialize(serialized: string): Ast { static deserialize(serialized: string): Ast {
const parsed: any = JSON.parse(serialized) const parsed: SerializedPrintedSource = JSON.parse(serialized)
const nodes: NodeSpanMap = new Map(Object.entries(parsed.info.nodes)) const nodes = new Map(unsafeEntries(parsed.info.nodes))
const tokens: TokenSpanMap = new Map(Object.entries(parsed.info.tokens)) const tokens = new Map(unsafeEntries(parsed.info.tokens))
const module = new Committed() const module = MutableModule.Transient()
const edit = new Edit(module)
const tree = parseEnso(parsed.code) const tree = parseEnso(parsed.code)
type NodeSpanMap = Map<NodeKey, AstId[]> const root = abstract(module, tree, parsed.code, { nodes, tokens }).node
type TokenSpanMap = Map<TokenKey, TokenId>
const root = abstract(edit, tree, parsed.code, { nodes, tokens }).node
edit.commit()
return module.get(root)! return module.get(root)!
} }
@ -176,8 +192,8 @@ export abstract class Ast {
/** Returns child subtrees, including information about the whitespace between them. */ /** Returns child subtrees, including information about the whitespace between them. */
abstract concreteChildren(): IterableIterator<NodeChild> abstract concreteChildren(): IterableIterator<NodeChild>
code(): string { code(module?: Module): string {
return print(this).code return print(this, module).code
} }
repr(): string { repr(): string {
@ -189,17 +205,25 @@ export abstract class Ast {
return RawAst.Tree.typeNames[this.treeType] return RawAst.Tree.typeNames[this.treeType]
} }
static parse(source: PrintedSource | string): Ast { static parse(source: PrintedSource | string, inModule?: MutableModule): Ast {
const code = typeof source === 'object' ? source.code : source const code = typeof source === 'object' ? source.code : source
const ids = typeof source === 'object' ? source.info : undefined const ids = typeof source === 'object' ? source.info : undefined
const tree = parseEnso(code) const tree = parseEnso(code)
const module = new Committed() const module = inModule ?? MutableModule.Observable()
const edit = new Edit(module) const newRoot = abstract(module, tree, code, ids).node
const newRoot = abstract(edit, tree, code, ids).node
edit.commit()
return module.get(newRoot)! return module.get(newRoot)!
} }
static parseLine(source: PrintedSource | string): Ast {
const ast = Ast.parse(source)
if (ast instanceof BodyBlock) {
const [expr] = ast.expressions()
return expr instanceof Ast ? expr : ast
} else {
return ast
}
}
visitRecursive(visit: (node: Ast | Token) => void) { visitRecursive(visit: (node: Ast | Token) => void) {
visit(this) visit(this)
for (const child of this.concreteChildren()) { for (const child of this.concreteChildren()) {
@ -211,21 +235,23 @@ export abstract class Ast {
} }
} }
protected constructor(module: Edit, id?: AstId, treeType?: RawAst.Tree.Type) { protected constructor(module: MutableModule, id?: AstId, treeType?: RawAst.Tree.Type) {
this.module = module.base this.module = module
this._id = id ?? newNodeId() this._id = id ?? newNodeId()
this.treeType = treeType this.treeType = treeType
module.set(this._id, this) module.set(this._id, this)
} }
_print(info: InfoMap, offset: number, indent: string): string { _print(
info: InfoMap,
offset: number,
indent: string,
moduleOverride?: Module | undefined,
): string {
const module_ = moduleOverride ?? this.module
let code = '' let code = ''
for (const child of this.concreteChildren()) { for (const child of this.concreteChildren()) {
if ( if (child.node != null && !(child.node instanceof Token) && module_.get(child.node) === null)
child.node != null &&
!(child.node instanceof Token) &&
this.module.get(child.node) === null
)
continue continue
if (child.whitespace != null) { if (child.whitespace != null) {
code += child.whitespace code += child.whitespace
@ -237,7 +263,9 @@ export abstract class Ast {
if (child.node instanceof Token) { if (child.node instanceof Token) {
code += child.node.code() code += child.node.code()
} else { } else {
code += this.module.get(child.node)!._print(info, offset + code.length, indent) code += module_
.get(child.node)!
._print(info, offset + code.length, indent, moduleOverride)
} }
} }
} }
@ -253,12 +281,12 @@ export abstract class Ast {
} }
export class App extends Ast { export class App extends Ast {
private _func: NodeChild<AstId> _func: NodeChild<AstId>
private _leftParen: NodeChild<Token> | null _leftParen: NodeChild<Token> | null
private _argumentName: NodeChild<Token> | null _argumentName: NodeChild<Token> | null
private _equals: NodeChild<Token> | null _equals: NodeChild<Token> | null
private _arg: NodeChild<AstId> _arg: NodeChild<AstId>
private _rightParen: NodeChild<Token> | null _rightParen: NodeChild<Token> | null
get function(): Ast { get function(): Ast {
return this.module.get(this._func.node)! return this.module.get(this._func.node)!
@ -273,7 +301,7 @@ export class App extends Ast {
} }
constructor( constructor(
module: Edit, module: MutableModule,
id: AstId | undefined, id: AstId | undefined,
func: NodeChild<AstId>, func: NodeChild<AstId>,
leftParen: NodeChild<Token> | null, leftParen: NodeChild<Token> | null,
@ -303,8 +331,26 @@ export class App extends Ast {
} }
} }
const mapping: Record<string, string> = {
'\b': '\\b',
'\f': '\\f',
'\n': '\\n',
'\r': '\\r',
'\t': '\\t',
'\v': '\\v',
'"': '\\"',
"'": "\\'",
'`': '``',
}
/** Escape a string so it can be safely spliced into an interpolated (`''`) Enso string.
* NOT USABLE to insert into raw strings. Does not include quotes. */
export function escape(string: string) {
return string.replace(/[\0\b\f\n\r\t\v"'`]/g, (match) => mapping[match]!)
}
function positionalApp( function positionalApp(
module: Edit, module: MutableModule,
id: AstId | undefined, id: AstId | undefined,
func: NodeChild<AstId>, func: NodeChild<AstId>,
arg: NodeChild<AstId>, arg: NodeChild<AstId>,
@ -313,7 +359,7 @@ function positionalApp(
} }
function namedApp( function namedApp(
module: Edit, module: MutableModule,
id: AstId | undefined, id: AstId | undefined,
func: NodeChild<AstId>, func: NodeChild<AstId>,
leftParen: NodeChild<Token> | null, leftParen: NodeChild<Token> | null,
@ -336,8 +382,8 @@ function namedApp(
} }
export class UnaryOprApp extends Ast { export class UnaryOprApp extends Ast {
private _opr: NodeChild<Token> _opr: NodeChild<Token>
private _arg: NodeChild<AstId> | null _arg: NodeChild<AstId> | null
get operator(): Token { get operator(): Token {
return this._opr.node return this._opr.node
@ -349,7 +395,7 @@ export class UnaryOprApp extends Ast {
} }
constructor( constructor(
module: Edit, module: MutableModule,
id: AstId | undefined, id: AstId | undefined,
opr: NodeChild<Token>, opr: NodeChild<Token>,
arg: NodeChild<AstId> | null, arg: NodeChild<AstId> | null,
@ -367,7 +413,7 @@ export class UnaryOprApp extends Ast {
export class NegationOprApp extends UnaryOprApp { export class NegationOprApp extends UnaryOprApp {
constructor( constructor(
module: Edit, module: MutableModule,
id: AstId | undefined, id: AstId | undefined,
opr: NodeChild<Token>, opr: NodeChild<Token>,
arg: NodeChild<AstId> | null, arg: NodeChild<AstId> | null,
@ -377,9 +423,9 @@ export class NegationOprApp extends UnaryOprApp {
} }
export class OprApp extends Ast { export class OprApp extends Ast {
protected _lhs: NodeChild<AstId> | null _lhs: NodeChild<AstId> | null
protected _opr: NodeChild[] _opr: NodeChild[]
protected _rhs: NodeChild<AstId> | null _rhs: NodeChild<AstId> | null
get lhs(): Ast | null { get lhs(): Ast | null {
return this._lhs ? this.module.get(this._lhs.node) : null return this._lhs ? this.module.get(this._lhs.node) : null
@ -399,7 +445,7 @@ export class OprApp extends Ast {
} }
constructor( constructor(
module: Edit, module: MutableModule,
id: AstId | undefined, id: AstId | undefined,
lhs: NodeChild<AstId> | null, lhs: NodeChild<AstId> | null,
opr: NodeChild[], opr: NodeChild[],
@ -420,7 +466,7 @@ export class OprApp extends Ast {
export class PropertyAccess extends OprApp { export class PropertyAccess extends OprApp {
constructor( constructor(
module: Edit, module: MutableModule,
id: AstId | undefined, id: AstId | undefined,
lhs: NodeChild<AstId> | null, lhs: NodeChild<AstId> | null,
opr: NodeChild<Token>, opr: NodeChild<Token>,
@ -432,9 +478,14 @@ export class PropertyAccess extends OprApp {
/** Representation without any type-specific accessors, for tree types that don't require any special treatment. */ /** Representation without any type-specific accessors, for tree types that don't require any special treatment. */
export class Generic extends Ast { export class Generic extends Ast {
private readonly _children: NodeChild[] _children: NodeChild[]
constructor(module: Edit, id?: AstId, children?: NodeChild[], treeType?: RawAst.Tree.Type) { constructor(
module: MutableModule,
id?: AstId,
children?: NodeChild[],
treeType?: RawAst.Tree.Type,
) {
super(module, id, treeType) super(module, id, treeType)
this._children = children ?? [] this._children = children ?? []
} }
@ -447,39 +498,39 @@ export class Generic extends Ast {
type MultiSegmentAppSegment = { header: NodeChild<Token>; body: NodeChild<AstId> | null } type MultiSegmentAppSegment = { header: NodeChild<Token>; body: NodeChild<AstId> | null }
export class Import extends Ast { export class Import extends Ast {
private polyglot_: MultiSegmentAppSegment | null _polyglot: MultiSegmentAppSegment | null
private from_: MultiSegmentAppSegment | null _from: MultiSegmentAppSegment | null
private import__: MultiSegmentAppSegment _import: MultiSegmentAppSegment
private all_: NodeChild<Token> | null _all: NodeChild<Token> | null
private as_: MultiSegmentAppSegment | null _as: MultiSegmentAppSegment | null
private hiding_: MultiSegmentAppSegment | null _hiding: MultiSegmentAppSegment | null
get polyglot(): Ast | null { get polyglot(): Ast | null {
return this.polyglot_?.body ? this.module.get(this.polyglot_.body.node) : null return this._polyglot?.body ? this.module.get(this._polyglot.body.node) : null
} }
get from(): Ast | null { get from(): Ast | null {
return this.from_?.body ? this.module.get(this.from_.body.node) : null return this._from?.body ? this.module.get(this._from.body.node) : null
} }
get import_(): Ast | null { get import_(): Ast | null {
return this.import__?.body ? this.module.get(this.import__.body.node) : null return this._import?.body ? this.module.get(this._import.body.node) : null
} }
get all(): Token | null { get all(): Token | null {
return this.all_?.node ?? null return this._all?.node ?? null
} }
get as(): Ast | null { get as(): Ast | null {
return this.as_?.body ? this.module.get(this.as_.body.node) : null return this._as?.body ? this.module.get(this._as.body.node) : null
} }
get hiding(): Ast | null { get hiding(): Ast | null {
return this.hiding_?.body ? this.module.get(this.hiding_.body.node) : null return this._hiding?.body ? this.module.get(this._hiding.body.node) : null
} }
constructor( constructor(
module: Edit, module: MutableModule,
id: AstId | undefined, id: AstId | undefined,
polyglot: MultiSegmentAppSegment | null, polyglot: MultiSegmentAppSegment | null,
from: MultiSegmentAppSegment | null, from: MultiSegmentAppSegment | null,
@ -489,12 +540,12 @@ export class Import extends Ast {
hiding: MultiSegmentAppSegment | null, hiding: MultiSegmentAppSegment | null,
) { ) {
super(module, id, RawAst.Tree.Type.Import) super(module, id, RawAst.Tree.Type.Import)
this.polyglot_ = polyglot this._polyglot = polyglot
this.from_ = from this._from = from
this.import__ = import_ this._import = import_
this.all_ = all this._all = all
this.as_ = as this._as = as
this.hiding_ = hiding this._hiding = hiding
} }
*concreteChildren(): IterableIterator<NodeChild> { *concreteChildren(): IterableIterator<NodeChild> {
@ -504,23 +555,23 @@ export class Import extends Ast {
if (segment?.body) parts.push(segment.body) if (segment?.body) parts.push(segment.body)
return parts return parts
} }
yield* segment(this.polyglot_) yield* segment(this._polyglot)
yield* segment(this.from_) yield* segment(this._from)
yield* segment(this.import__) yield* segment(this._import)
if (this.all_) yield this.all_ if (this._all) yield this._all
yield* segment(this.as_) yield* segment(this._as)
yield* segment(this.hiding_) yield* segment(this._hiding)
} }
} }
export class TextLiteral extends Ast { export class TextLiteral extends Ast {
private readonly open_: NodeChild<Token> | null _open: NodeChild<Token> | null
private readonly newline_: NodeChild<Token> | null _newline: NodeChild<Token> | null
private readonly elements_: NodeChild[] _elements: NodeChild[]
private readonly close_: NodeChild<Token> | null _close: NodeChild<Token> | null
constructor( constructor(
module: Edit, module: MutableModule,
id: AstId | undefined, id: AstId | undefined,
open: NodeChild<Token> | null, open: NodeChild<Token> | null,
newline: NodeChild<Token> | null, newline: NodeChild<Token> | null,
@ -528,62 +579,70 @@ export class TextLiteral extends Ast {
close: NodeChild<Token> | null, close: NodeChild<Token> | null,
) { ) {
super(module, id, RawAst.Tree.Type.TextLiteral) super(module, id, RawAst.Tree.Type.TextLiteral)
this.open_ = open this._open = open
this.newline_ = newline this._newline = newline
this.elements_ = elements this._elements = elements
this.close_ = close this._close = close
}
static new(rawText: string): TextLiteral {
const module = MutableModule.Transient()
const text = Token.new(escape(rawText))
return new TextLiteral(module, undefined, { node: Token.new("'") }, null, [{ node: text }], {
node: Token.new("'"),
})
} }
*concreteChildren(): IterableIterator<NodeChild> { *concreteChildren(): IterableIterator<NodeChild> {
if (this.open_) yield this.open_ if (this._open) yield this._open
if (this.newline_) yield this.newline_ if (this._newline) yield this._newline
yield* this.elements_ yield* this._elements
if (this.close_) yield this.close_ if (this._close) yield this._close
} }
} }
export class Invalid extends Ast { export class Invalid extends Ast {
private readonly expression_: NodeChild<AstId> _expression: NodeChild<AstId>
constructor(module: Edit, id: AstId | undefined, expression: NodeChild<AstId>) { constructor(module: MutableModule, id: AstId | undefined, expression: NodeChild<AstId>) {
super(module, id, RawAst.Tree.Type.Invalid) super(module, id, RawAst.Tree.Type.Invalid)
this.expression_ = expression this._expression = expression
} }
*concreteChildren(): IterableIterator<NodeChild> { *concreteChildren(): IterableIterator<NodeChild> {
yield this.expression_ yield this._expression
} }
} }
export class Group extends Ast { export class Group extends Ast {
private readonly open_: NodeChild<Token> | undefined _open: NodeChild<Token> | undefined
private readonly expression_: NodeChild<AstId> | null _expression: NodeChild<AstId> | null
private readonly close_: NodeChild<Token> | undefined _close: NodeChild<Token> | undefined
constructor( constructor(
module: Edit, module: MutableModule,
id: AstId | undefined, id: AstId | undefined,
open: NodeChild<Token> | undefined, open: NodeChild<Token> | undefined,
expression: NodeChild<AstId> | null, expression: NodeChild<AstId> | null,
close: NodeChild<Token> | undefined, close: NodeChild<Token> | undefined,
) { ) {
super(module, id, RawAst.Tree.Type.Group) super(module, id, RawAst.Tree.Type.Group)
this.open_ = open this._open = open
this.expression_ = expression this._expression = expression
this.close_ = close this._close = close
} }
*concreteChildren(): IterableIterator<NodeChild> { *concreteChildren(): IterableIterator<NodeChild> {
if (this.open_) yield this.open_ if (this._open) yield this._open
if (this.expression_) yield this.expression_ if (this._expression) yield this._expression
if (this.close_) yield this.close_ if (this._close) yield this._close
} }
} }
export class NumericLiteral extends Ast { export class NumericLiteral extends Ast {
private readonly _tokens: NodeChild[] _tokens: NodeChild[]
constructor(module: Edit, id: AstId | undefined, tokens: NodeChild[]) { constructor(module: MutableModule, id: AstId | undefined, tokens: NodeChild[]) {
super(module, id, RawAst.Tree.Type.Number) super(module, id, RawAst.Tree.Type.Number)
this._tokens = tokens ?? [] this._tokens = tokens ?? []
} }
@ -594,11 +653,12 @@ export class NumericLiteral extends Ast {
} }
type FunctionArgument = NodeChild[] type FunctionArgument = NodeChild[]
export class Function extends Ast { export class Function extends Ast {
private _name: NodeChild<AstId> _name: NodeChild<AstId>
private _args: FunctionArgument[] _args: FunctionArgument[]
private _equals: NodeChild<Token> _equals: NodeChild<Token>
private _body: NodeChild<AstId> | null _body: NodeChild<AstId> | null
// FIXME for #8367: This should not be nullable. If the `ExprId` has been deleted, the same placeholder logic should be applied // FIXME for #8367: This should not be nullable. If the `ExprId` has been deleted, the same placeholder logic should be applied
// here and in `rawChildren` (and indirectly, `print`). // here and in `rawChildren` (and indirectly, `print`).
get name(): Ast | null { get name(): Ast | null {
@ -616,7 +676,7 @@ export class Function extends Ast {
} }
} }
constructor( constructor(
module: Edit, module: MutableModule,
id: AstId | undefined, id: AstId | undefined,
name: NodeChild<AstId>, name: NodeChild<AstId>,
args: FunctionArgument[], args: FunctionArgument[],
@ -640,9 +700,9 @@ export class Function extends Ast {
} }
export class Assignment extends Ast { export class Assignment extends Ast {
private _pattern: NodeChild<AstId> _pattern: NodeChild<AstId>
private _equals: NodeChild<Token> _equals: NodeChild<Token>
private _expression: NodeChild<AstId> _expression: NodeChild<AstId>
get pattern(): Ast | null { get pattern(): Ast | null {
return this.module.get(this._pattern.node) return this.module.get(this._pattern.node)
} }
@ -650,7 +710,7 @@ export class Assignment extends Ast {
return this.module.get(this._expression.node) return this.module.get(this._expression.node)
} }
constructor( constructor(
module: Edit, module: MutableModule,
id: AstId | undefined, id: AstId | undefined,
pattern: NodeChild<AstId>, pattern: NodeChild<AstId>,
equals: NodeChild<Token>, // TODO: Edits (#8367): Allow undefined equals: NodeChild<Token>, // TODO: Edits (#8367): Allow undefined
@ -682,12 +742,13 @@ export class Assignment extends Ast {
} }
} }
type BlockLine = { interface BlockLine {
newline: NodeChild<Token> // Edits (#8367): Allow undefined newline: NodeChild<Token> // Edits (#8367): Allow undefined
expression: NodeChild<AstId> | null expression: NodeChild<AstId> | null
} }
export class BodyBlock extends Ast { export class BodyBlock extends Ast {
private _lines: BlockLine[]; _lines: BlockLine[];
*expressions(): IterableIterator<Ast> { *expressions(): IterableIterator<Ast> {
for (const line of this._lines) { for (const line of this._lines) {
@ -702,7 +763,7 @@ export class BodyBlock extends Ast {
} }
} }
constructor(module: Edit, id: AstId | undefined, lines: BlockLine[]) { constructor(module: MutableModule, id: AstId | undefined, lines: BlockLine[]) {
super(module, id, RawAst.Tree.Type.BodyBlock) super(module, id, RawAst.Tree.Type.BodyBlock)
this._lines = lines this._lines = lines
} }
@ -730,16 +791,24 @@ export class BodyBlock extends Ast {
} }
} }
_print(info: InfoMap, offset: number, indent: string): string { _print(
info: InfoMap,
offset: number,
indent: string,
moduleOverride?: Module | undefined,
): string {
const module_ = moduleOverride ?? this.module
let code = '' let code = ''
for (const line of this._lines) { for (const line of this._lines) {
if (line.expression?.node != null && this.module.get(line.expression.node) === null) continue if (line.expression?.node != null && module_.get(line.expression.node) === null) continue
code += line.newline?.whitespace ?? '' code += line.newline?.whitespace ?? ''
code += line.newline?.node.code() ?? '\n' code += line.newline?.node.code() ?? '\n'
if (line.expression !== null) { if (line.expression !== null) {
code += line.expression.whitespace ?? indent code += line.expression.whitespace ?? indent
if (line.expression.node !== null) { if (line.expression.node !== null) {
code += this.module.get(line.expression.node)!._print(info, offset, indent + ' ') code += module_
.get(line.expression.node)!
._print(info, offset, indent + ' ', moduleOverride)
} }
} }
} }
@ -757,7 +826,7 @@ export class BodyBlock extends Ast {
export class Ident extends Ast { export class Ident extends Ast {
public token: NodeChild<Token> public token: NodeChild<Token>
constructor(module: Edit, id: AstId | undefined, token: NodeChild<Token>) { constructor(module: MutableModule, id: AstId | undefined, token: NodeChild<Token>) {
super(module, id, RawAst.Tree.Type.Ident) super(module, id, RawAst.Tree.Type.Ident)
this.token = token this.token = token
} }
@ -777,18 +846,16 @@ export class Ident extends Ast {
export class Wildcard extends Ast { export class Wildcard extends Ast {
public token: NodeChild<Token> public token: NodeChild<Token>
constructor(module: Edit, id: AstId | undefined, token: NodeChild<Token>) { constructor(module: MutableModule, id: AstId | undefined, token: NodeChild<Token>) {
super(module, id, RawAst.Tree.Type.Wildcard) super(module, id, RawAst.Tree.Type.Wildcard)
this.token = token this.token = token
} }
static new(): Wildcard { static new(): Wildcard {
const module = new Committed() const module = MutableModule.Transient()
const edit = new Edit(module) const ast = new Wildcard(module, undefined, {
const ast = new Wildcard(edit, undefined, {
node: new Token('_', newTokenId(), RawAst.Token.Type.Wildcard), node: new Token('_', newTokenId(), RawAst.Token.Type.Wildcard),
}) })
edit.commit()
return ast return ast
} }
@ -798,9 +865,9 @@ export class Wildcard extends Ast {
} }
export class RawCode extends Ast { export class RawCode extends Ast {
private _code: NodeChild _code: NodeChild
constructor(module: Edit, id: AstId | undefined, code: NodeChild) { constructor(module: MutableModule, id: AstId | undefined, code: NodeChild) {
super(module, id) super(module, id)
this._code = code this._code = code
} }
@ -819,7 +886,7 @@ export class RawCode extends Ast {
} }
function abstract( function abstract(
module: Edit, module: MutableModule,
tree: RawAst.Tree, tree: RawAst.Tree,
code: string, code: string,
info: InfoMap | undefined, info: InfoMap | undefined,
@ -830,8 +897,9 @@ function abstract(
const tokenIds = info?.tokens ?? new Map() const tokenIds = info?.tokens ?? new Map()
return abstractTree(module, tree, code, nodesExpected, tokenIds) return abstractTree(module, tree, code, nodesExpected, tokenIds)
} }
function abstractTree( function abstractTree(
module: Edit, module: MutableModule,
tree: RawAst.Tree, tree: RawAst.Tree,
code: string, code: string,
nodesExpected: NodeSpanMap, nodesExpected: NodeSpanMap,
@ -858,8 +926,9 @@ function abstractTree(
const whitespaceEnd = whitespaceStart + tree.whitespaceLengthInCodeParsed const whitespaceEnd = whitespaceStart + tree.whitespaceLengthInCodeParsed
const codeStart = whitespaceEnd const codeStart = whitespaceEnd
const codeEnd = codeStart + tree.childrenLengthInCodeParsed const codeEnd = codeStart + tree.childrenLengthInCodeParsed
// All node types use this value in the same way to obtain the ID type, but each node does so separately because we // All node types use this value in the same way to obtain the ID type,
// must pop the tree's span from the ID map *after* processing children. // but each node does so separately because we must pop the tree's span from the ID map
// *after* processing children.
const spanKey = nodeKey(codeStart, codeEnd - codeStart, tree.type) const spanKey = nodeKey(codeStart, codeEnd - codeStart, tree.type)
let node: AstId let node: AstId
switch (tree.type) { switch (tree.type) {
@ -1030,54 +1099,68 @@ function abstractToken(
return { whitespace, node } return { whitespace, node }
} }
type NodeKey = string declare const nodeKeyBrand: unique symbol
type TokenKey = string type NodeKey = string & { [nodeKeyBrand]: never }
function nodeKey(start: number, length: number, type: RawAst.Tree.Type | undefined): NodeKey { declare const tokenKeyBrand: unique symbol
type TokenKey = string & { [tokenKeyBrand]: never }
function nodeKey(start: number, length: number, type: Opt<RawAst.Tree.Type>): NodeKey {
const type_ = type?.toString() ?? '?' const type_ = type?.toString() ?? '?'
return `${start}:${length}:${type_}` return `${start}:${length}:${type_}` as NodeKey
} }
function tokenKey(start: number, length: number): TokenKey { function tokenKey(start: number, length: number): TokenKey {
return `${start}:${length}` return `${start}:${length}` as TokenKey
}
interface SerializedInfoMap {
nodes: Record<NodeKey, AstId[]>
tokens: Record<TokenKey, TokenId>
}
interface SerializedPrintedSource {
info: SerializedInfoMap
code: string
} }
type NodeSpanMap = Map<NodeKey, AstId[]> type NodeSpanMap = Map<NodeKey, AstId[]>
type TokenSpanMap = Map<TokenKey, TokenId> type TokenSpanMap = Map<TokenKey, TokenId>
export type InfoMap = {
export interface InfoMap {
nodes: NodeSpanMap nodes: NodeSpanMap
tokens: TokenSpanMap tokens: TokenSpanMap
} }
type PrintedSource = { interface PrintedSource {
info: InfoMap info: InfoMap
code: string code: string
} }
/** Return stringification with associated ID map. This is only exported for testing. */ /** Return stringification with associated ID map. This is only exported for testing. */
export function print(ast: Ast): PrintedSource { export function print(ast: Ast, module?: Module | undefined): PrintedSource {
const info: InfoMap = { const info: InfoMap = {
nodes: new Map(), nodes: new Map(),
tokens: new Map(), tokens: new Map(),
} }
const code = ast._print(info, 0, '') const code = ast._print(info, 0, '', module)
return { info, code } return { info, code }
} }
type DebugTree = (DebugTree | string)[] export type TokenTree = (TokenTree | string)[]
export function debug(root: Ast, universe?: Map<AstId, Ast>): DebugTree {
export function tokenTree(root: Ast): TokenTree {
const module = root.module const module = root.module
return Array.from(root.concreteChildren(), (child) => { return Array.from(root.concreteChildren(), (child) => {
if (child.node instanceof Token) { if (child.node instanceof Token) {
return child.node.code() return child.node.code()
} else { } else {
const node = module.get(child.node) const node = module.get(child.node)
return node ? debug(node, universe) : '<missing>' return node ? tokenTree(node) : '<missing>'
} }
}) })
} }
// FIXME: We should use alias analysis to handle ambiguous names correctly. // FIXME: We should use alias analysis to handle ambiguous names correctly.
export function findModuleMethod(module: Committed, name: string): Function | null { export function findModuleMethod(module: Module, name: string): Function | null {
for (const node of module.nodes.values()) { for (const node of module.raw.nodes.values()) {
if (node instanceof Function) { if (node instanceof Function) {
if (node.name && node.name.code() === name) { if (node.name && node.name.code() === name) {
return node return node
@ -1087,7 +1170,7 @@ export function findModuleMethod(module: Committed, name: string): Function | nu
return null return null
} }
export function functionBlock(module: Committed, name: string): BodyBlock | null { export function functionBlock(module: Module, name: string): BodyBlock | null {
const method = findModuleMethod(module, name) const method = findModuleMethod(module, name)
if (!method || !(method.body instanceof BodyBlock)) return null if (!method || !(method.body instanceof BodyBlock)) return null
return method.body return method.body
@ -1120,7 +1203,7 @@ export function parseTransitional(code: string, idMap: IdMap): Ast {
idMap.finishAndSynchronize() idMap.finishAndSynchronize()
const nodes = new Map<NodeKey, AstId[]>() const nodes = new Map<NodeKey, AstId[]>()
const tokens = new Map<TokenKey, TokenId>() const tokens = new Map<TokenKey, TokenId>()
const astExtended = new Map<AstId, RawAstExtended>() const astExtended = reactive(new Map<AstId, RawAstExtended>())
legacyAst.visitRecursive((nodeOrToken: RawAstExtended<RawAst.Tree | RawAst.Token>) => { legacyAst.visitRecursive((nodeOrToken: RawAstExtended<RawAst.Tree | RawAst.Token>) => {
const start = nodeOrToken.span()[0] const start = nodeOrToken.span()[0]
const length = nodeOrToken.span()[1] - nodeOrToken.span()[0] const length = nodeOrToken.span()[1] - nodeOrToken.span()[0]
@ -1154,13 +1237,12 @@ export function parseTransitional(code: string, idMap: IdMap): Ast {
return true return true
}) })
const newRoot = Ast.parse({ info: { nodes, tokens }, code }) const newRoot = Ast.parse({ info: { nodes, tokens }, code })
newRoot.module.astExtended = astExtended newRoot.module.raw.astExtended = astExtended
return newRoot return newRoot
} }
export function parse(source: PrintedSource | string): Ast { export const parse = Ast.parse
return Ast.parse(source) export const parseLine = Ast.parseLine
}
export function deserialize(serialized: string): Ast { export function deserialize(serialized: string): Ast {
return Ast.deserialize(serialized) return Ast.deserialize(serialized)

View File

@ -0,0 +1,98 @@
import { Ast } from '@/util/ast'
import { MutableModule } from '@/util/ast/abstract'
export class Pattern {
private readonly tokenTree: Ast.TokenTree
private readonly template: string
private readonly placeholder: string
constructor(template: string, placeholder: string) {
this.tokenTree = Ast.tokenTree(Ast.parseLine(template))
this.template = template
this.placeholder = placeholder
}
/** Parse an expression template in which a specified identifier (by default `__`)
* may match any arbitrary subtree. */
static parse(template: string, placeholder: string = '__'): Pattern {
return new Pattern(template, placeholder)
}
/** If the given expression matches the pattern, return the subtrees that matched the holes in the pattern. */
match(target: Ast.Ast): Ast.AstId[] | undefined {
const extracted: Ast.AstId[] = []
if (this.tokenTree.length === 1 && this.tokenTree[0] === this.placeholder) {
return [target.exprId]
}
if (
isMatch_(
this.tokenTree,
target.concreteChildren(),
target.module,
this.placeholder,
extracted,
)
) {
return extracted
}
}
/** Create a new concrete example of the pattern, with the placeholders replaced with the given subtrees.
* The subtree IDs provided must be accessible in the `edit` module. */
instantiate(edit: MutableModule, subtrees: Ast.AstId[]): Ast.Ast {
const ast = Ast.parse(this.template, edit)
for (const matched of placeholders(ast, this.placeholder)) {
const replacement = subtrees.shift()
if (replacement === undefined) break
matched.node = replacement
}
return ast
}
}
function isMatch_(
pattern: Ast.TokenTree,
target: Iterator<Ast.NodeChild>,
module: Ast.Module,
placeholder: string,
extracted: Ast.AstId[],
): boolean {
for (const subpattern of pattern) {
const next = target.next()
if (next.done) return false
const astOrToken = next.value.node
const isPlaceholder = typeof subpattern !== 'string' && subpattern[0] === placeholder
if (typeof subpattern === 'string') {
if (!(astOrToken instanceof Ast.Token) || astOrToken.code() !== subpattern) {
return false
}
} else if (astOrToken instanceof Ast.Token) {
return false
} else if (isPlaceholder) {
extracted.push(astOrToken)
} else {
const ast = module.get(astOrToken)
if (!ast) return false
if (!isMatch_(subpattern, ast.concreteChildren(), module, placeholder, extracted))
return false
}
}
return true
}
function placeholders(ast: Ast.Ast, placeholder: string, outIn?: Ast.NodeChild<Ast.AstId>[]) {
const out = outIn ?? []
for (const child of ast.concreteChildren()) {
if (!(child.node instanceof Ast.Token)) {
// The type of `child` has been determined by checking the type of `child.node`
const nodeChild = child as Ast.NodeChild<Ast.AstId>
const subtree = ast.module.get(child.node)!
if (subtree instanceof Ast.Ident && subtree.code() === placeholder) {
out.push(nodeChild)
} else {
placeholders(subtree, placeholder, out)
}
}
}
return out
}

View File

@ -0,0 +1,23 @@
import type { Node } from '@/stores/graph'
import { Ast } from '@/util/ast'
import { Vec2 } from '@/util/data/vec2'
export function nodeFromAst(ast: Ast.Ast): Node {
if (ast instanceof Ast.Assignment) {
return {
outerExprId: ast.astId,
pattern: ast.pattern ?? undefined,
rootSpan: ast.expression ?? ast,
position: Vec2.Zero,
vis: undefined,
}
} else {
return {
outerExprId: ast.astId,
pattern: undefined,
rootSpan: ast,
position: Vec2.Zero,
vis: undefined,
}
}
}

View File

@ -0,0 +1,60 @@
import { Ast } from '@/util/ast'
import { Pattern } from '@/util/ast/match'
import { unsafeKeys } from '@/util/record'
type Matches<T> = Record<keyof T, Ast.Ast[] | undefined>
interface MatchResult<T> {
innerExpr: Ast.Ast
matches: Record<keyof T, Ast.Ast[] | undefined>
}
export class Prefixes<T extends Record<keyof T, Pattern>> {
constructor(
/** Note that these are checked in order of definition. */
public prefixes: T,
) {}
/** Note that these are checked in order of definition. */
static FromLines<T>(lines: Record<keyof T, string>) {
return new Prefixes(
Object.fromEntries(
Object.entries<string>(lines).map(([name, line]) => [name, Pattern.parse(line)]),
) as Record<keyof T, Pattern>,
)
}
extractMatches(expression: Ast.Ast): MatchResult<T> {
const matches = Object.fromEntries(
Object.entries<Pattern>(this.prefixes).map(([name, pattern]) => {
const matchIds = pattern.match(expression)
const matches = matchIds
? Array.from(matchIds, (id) => expression.module.get(id)!)
: undefined
const lastMatch = matches != null ? matches[matches.length - 1] : undefined
if (lastMatch) expression = lastMatch
return [name, matches]
}),
) as Matches<T>
return { matches, innerExpr: expression }
}
modify(
edit: Ast.MutableModule,
expression: Ast.Ast,
replacements: Partial<Record<keyof T, Ast.Ast[] | undefined>>,
) {
const matches = this.extractMatches(expression)
let result = matches.innerExpr
for (const key of unsafeKeys(this.prefixes).reverse()) {
if (key in replacements && !replacements[key]) continue
const replacement: Ast.Ast[] | undefined = replacements[key] ?? matches.matches[key]
if (!replacement) continue
const pattern = this.prefixes[key]
const parts = [...replacement, result]
const partsIds = Array.from(parts, (ast) => ast.exprId)
result = pattern.instantiate(edit, partsIds)
}
return result
}
}

View File

@ -36,15 +36,27 @@ export function* chain<T>(...iters: Iterable<T>[]) {
export function* zip<T, U>(left: Iterable<T>, right: Iterable<U>): Generator<[T, U]> { export function* zip<T, U>(left: Iterable<T>, right: Iterable<U>): Generator<[T, U]> {
const leftIterator = left[Symbol.iterator]() const leftIterator = left[Symbol.iterator]()
const rightIterator = right[Symbol.iterator]() const rightIterator = right[Symbol.iterator]()
while (true) { while (true) {
const leftResult = leftIterator.next() const leftResult = leftIterator.next()
const rightResult = rightIterator.next() const rightResult = rightIterator.next()
if (leftResult.done || rightResult.done) break
if (leftResult.done || rightResult.done) {
break
}
yield [leftResult.value, rightResult.value] yield [leftResult.value, rightResult.value]
} }
} }
export function* zipLongest<T, U>(
left: Iterable<T>,
right: Iterable<U>,
): Generator<[T | undefined, U | undefined]> {
const leftIterator = left[Symbol.iterator]()
const rightIterator = right[Symbol.iterator]()
while (true) {
const leftResult = leftIterator.next()
const rightResult = rightIterator.next()
if (leftResult.done && rightResult.done) break
yield [
leftResult.done ? undefined : leftResult.value,
rightResult.done ? undefined : rightResult.value,
]
}
}

View File

@ -211,7 +211,16 @@ export class AsyncQueue<State> {
if (task == null) return if (task == null) return
this.taskRunning = true this.taskRunning = true
this.lastTask = this.lastTask this.lastTask = this.lastTask
.then((state) => task(state)) .then(
(state) => task(state),
(error) => {
console.error(
"AsyncQueue failed to run task '" + task.toString() + "' with error:",
error,
)
throw error
},
)
.finally(() => { .finally(() => {
this.taskRunning = false this.taskRunning = false
this.run() this.run()

View File

@ -0,0 +1,9 @@
/** Unsafe whe the record can have extra keys which are not in `K`. */
export function unsafeEntries<K extends PropertyKey, V>(obj: Record<K, V>): [K, V][] {
return Object.entries(obj) as any
}
/** Unsafe whe the record can have extra keys which are not in `K`. */
export function unsafeKeys<K extends PropertyKey>(obj: Record<K, unknown>): K[] {
return Object.keys(obj) as any
}

View File

@ -4,5 +4,21 @@
"corner_radius": 16, "corner_radius": 16,
"vertical_gap": 32, "vertical_gap": 32,
"horizontal_gap": 32 "horizontal_gap": 32
},
"edge": {
"min_approach_height": 32,
"radius": 20,
"one_corner": {
"radius_y_adjustment": 29,
"radius_x_base": 20,
"radius_x_factor": 0.6,
"source_node_overlap": 4,
"minimum_tangent_exit_radius": 2
},
"three_corner": {
"radius_max": 20,
"backward_edge_arrow_threshold": 15,
"max_squeeze": 2
}
} }
} }

View File

@ -0,0 +1,60 @@
import originalTheme from '@/util/theme.json'
const theme: Theme = originalTheme
export default theme
export interface Theme {
/** Configuration for node rendering. */
node: NodeTheme
/** Configuration for edge rendering. */
edge: EdgeTheme
}
/** Configuration for node rendering. */
export interface NodeTheme {
/** The default height of a node. */
height: number
/** The maximum corner radius of a node. If the node is shorter than `2 * corner_radius`,
* the corner radius will be half of the node's height instead. */
corner_radius: number
/** The vertical gap between nodes in automatic layout. */
vertical_gap: number
/** The horizontal gap between nodes in automatic layout. */
horizontal_gap: number
}
/** Configuration for edge rendering. */
export interface EdgeTheme {
/** Minimum height above the target the edge must approach it from. */
min_approach_height: number
/** The preferred arc radius for corners, when an edge changes direction. */
radius: number
/** Configuration for edges that change direction once. */
one_corner: EdgeOneCornerTheme
/** Configuration for edges with change direction three times. */
three_corner: EdgeThreeCornerTheme
}
/** Configuration for edges that change direction once. */
export interface EdgeOneCornerTheme {
/** The y-allocation for the radius will be the full available height minus this value. */
radius_y_adjustment: number
/** The base x-allocation for the radius. */
radius_x_base: number
/** Proportion (0-1) of extra x-distance allocated to the radius. */
radius_x_factor: number
/** Distance for the line to continue under the node, to ensure that there isn't a gap. */
source_node_overlap: number
/** Minimum arc radius at which we offset the source end to exit normal to the node's curve. */
minimum_tangent_exit_radius: number
}
/** Configuration for edges with change direction three times. */
export interface EdgeThreeCornerTheme {
/** The maximum arc radius. */
radius_max: number
backward_edge_arrow_threshold: number
/** The maximum radius reduction (from [`RADIUS_BASE`]) to allow when choosing whether to use
* the three-corner layout that doesn't use a backward corner. */
max_squeeze: number
}

View File

@ -6,7 +6,9 @@
"src/**/*.vue", "src/**/*.vue",
"shared/**/*", "shared/**/*",
"shared/**/*.vue", "shared/**/*.vue",
"src/util/theme.json" "src/util/theme.json",
"stories/mockSuggestions.json",
"mock/**/*"
], ],
"exclude": ["src/**/__tests__/*", "shared/**/__tests__/*", "public/**/__tests__/*"], "exclude": ["src/**/__tests__/*", "shared/**/__tests__/*", "public/**/__tests__/*"],
"compilerOptions": { "compilerOptions": {
@ -15,6 +17,7 @@
"composite": true, "composite": true,
"outDir": "../../node_modules/.cache/tsc", "outDir": "../../node_modules/.cache/tsc",
"baseUrl": ".", "baseUrl": ".",
"noEmit": true,
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true, "exactOptionalPropertyTypes": true,

View File

@ -8,7 +8,8 @@
"histoire.config.ts", "histoire.config.ts",
"e2e/**/*", "e2e/**/*",
"parser-codegen/**/*", "parser-codegen/**/*",
"node.env.d.ts" "node.env.d.ts",
"mock/engine.ts"
], ],
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",