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">
import { computed } from 'vue'
import { getMainFile, setMainFile } from '../mock/engine'
import App from '../src/App.vue'
import MockProjectStoreWrapper from '../stories/MockProjectStoreWrapper.vue'
import { getMainFile, setMainFile } from './mockEngine'
const mainFile = computed({
get() {

View File

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

View File

@ -6,7 +6,7 @@ import {
type ProjectId,
type ProjectName,
type UTCDateTime,
} from './mockProjectManager'
} from '../mock/projectManager'
import pmSpec from './pm-openrpc.json' assert { type: 'json' }
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
/** A name of a project. */
/** An ID of a project. */
export type ProjectId = string & { [projectIdBrand]: never }
declare const projectNameBrand: unique symbol
/** A name of a project. */
export type ProjectName = string & { [projectNameBrand]: never }
declare const utcDateTimeBrand: unique symbol
/** A name of a project. */
/** A UTC date and time. */
export type UTCDateTime = string & { [utcDateTimeBrand]: never }
/** 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"/>
</svg>
</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">
<svg width="17" height="17" viewBox="0 0 17 17" fill="none"
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'
const props = defineProps<{
isAutoEvaluationDisabled: boolean
isOutputContextEnabledGlobally: boolean
isOutputContextOverridden: boolean
isDocsVisible: boolean
isVisualizationVisible: boolean
}>()
const emit = defineEmits<{
'update:isAutoEvaluationDisabled': [isAutoEvaluationDisabled: boolean]
'update:isOutputContextOverridden': [isOutputContextOverridden: boolean]
'update:isDocsVisible': [isDocsVisible: boolean]
'update:isVisualizationVisible': [isVisualizationVisible: boolean]
}>()
@ -16,11 +17,16 @@ const emit = defineEmits<{
<template>
<div class="CircularMenu">
<ToggleIcon
icon="no_auto_replay"
class="icon-container button no-auto-evaluate-button"
:alt="`${props.isAutoEvaluationDisabled ? 'Enable' : 'Disable'} auto-evaluation`"
:modelValue="props.isAutoEvaluationDisabled"
@update:modelValue="emit('update:isAutoEvaluationDisabled', $event)"
:icon="props.isOutputContextEnabledGlobally ? 'no_auto_replay' : 'auto_replay'"
class="icon-container button override-output-context-button"
:class="{ 'output-context-overridden': props.isOutputContextOverridden }"
:alt="`${
props.isOutputContextEnabledGlobally != props.isOutputContextOverridden
? 'Disable'
: 'Enable'
} output context`"
:modelValue="props.isOutputContextOverridden"
@update:modelValue="emit('update:isOutputContextOverridden', $event)"
/>
<ToggleIcon
icon="docs"
@ -70,12 +76,17 @@ const emit = defineEmits<{
opacity: unset;
}
.no-auto-evaluate-button {
.override-output-context-button {
position: absolute;
left: 9px;
top: 8px;
}
.output-context-overridden {
opacity: 100%;
color: red;
}
.docs-button {
position: absolute;
left: 18.54px;

View File

@ -5,7 +5,7 @@ import { useGraphStore, type Edge } from '@/stores/graph'
import { assert } from '@/util/assert'
import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2'
import theme from '@/util/theme.json'
import theme from '@/util/theme'
import { clamp } from '@vueuse/core'
import { computed, ref } from 'vue'
@ -79,7 +79,7 @@ const edgeColor = computed(
)
/** 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 edge may begin anywhere around the bottom half of the node. */
sourceSize: Vec2
@ -93,42 +93,12 @@ type Inputs = {
targetPortTopDistanceInNode: number | undefined
}
type JunctionPoints = {
interface JunctionPoints {
points: Vec2[]
maxRadius: number
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 {
let xNorm = clamp(x, -r2, r1)
return Math.sqrt(r1 * r1 + r2 * r2 - xNorm * xNorm)
@ -188,31 +158,26 @@ function junctionPoints(inputs: Inputs): JunctionPoints | null {
: undefined
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 targetBeyondSource = Math.abs(inputs.targetOffset.x) > sourceMaxXOffset
const horizontalRoomFor3Corners =
targetBeyondSource &&
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)) {
const {
RADIUS_Y_ADJUSTMENT,
RADIUS_X_BASE,
RADIUS_X_FACTOR,
SOURCE_NODE_OVERLAP,
MINIMUM_TANGENT_EXIT_RADIUS,
} = SingleCorner
const innerTheme = theme.edge.one_corner
// The edge can originate anywhere along the length of the node.
const sourceX = clamp(inputs.targetOffset.x, -sourceMaxXOffset, sourceMaxXOffset)
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
// 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.
const yAdjustment = Math.min(
Math.abs(inputs.targetOffset.x) - halfSourceSize.x + RADIUS_Y_ADJUSTMENT / 2.0,
RADIUS_Y_ADJUSTMENT,
Math.abs(inputs.targetOffset.x) - halfSourceSize.x + innerTheme.radius_y_adjustment / 2.0,
innerTheme.radius_y_adjustment,
)
const radiusY = Math.max(Math.abs(inputs.targetOffset.y) - yAdjustment, 0.0)
const maxRadius = Math.min(radiusX, radiusY)
@ -222,7 +187,7 @@ function junctionPoints(inputs: Inputs): JunctionPoints | null {
Math.abs(inputs.targetOffset.y),
)
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
// at the point that it exits the node.
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)
sourceDY = -Math.abs(radius - intersection)
} else if (halfSourceSize.y != 0) {
sourceDY = -SOURCE_NODE_OVERLAP + halfSourceSize.y
sourceDY = -innerTheme.source_node_overlap + halfSourceSize.y
}
const source = new Vec2(sourceX, sourceDY)
// 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,
}
} else {
const { RADIUS_MAX } = ThreeCorner
const radiusMax = theme.edge.three_corner.radius_max
// The edge originates from either side of the node.
const signX = Math.sign(inputs.targetOffset.x)
const sourceX = Math.abs(sourceMaxXOffset) * signX
@ -265,11 +230,11 @@ function junctionPoints(inputs: Inputs): JunctionPoints | null {
// \
// J0
// Junctions (J0, J1) are in between source and target.
const j0Dx = Math.min(2 * RADIUS_MAX, distanceX / 2)
const j1Dx = Math.min(RADIUS_MAX, (distanceX - j0Dx) / 2)
const j0Dx = Math.min(2 * radiusMax, distanceX / 2)
const j1Dx = Math.min(radiusMax, (distanceX - j0Dx) / 2)
j0x = sourceX + Math.abs(j0Dx) * signX
j1x = j0x + Math.abs(j1Dx) * signX
heightAdjustment = RADIUS_MAX - j1Dx
heightAdjustment = radiusMax - j1Dx
} else {
// J1
// /
@ -278,16 +243,16 @@ function junctionPoints(inputs: Inputs): JunctionPoints | null {
//
//
// J0 > source; J0 > J1; J1 > target.
j1x = inputs.targetOffset.x + Math.abs(RADIUS_MAX) * signX
const j0BeyondSource = Math.abs(inputs.targetOffset.x) + RADIUS_MAX * 2
const j0BeyondTarget = Math.abs(sourceX) + RADIUS_MAX
j1x = inputs.targetOffset.x + Math.abs(radiusMax) * signX
const j0BeyondSource = Math.abs(inputs.targetOffset.x) + radiusMax * 2
const j0BeyondTarget = Math.abs(sourceX) + radiusMax
j0x = Math.abs(Math.max(j0BeyondTarget, j0BeyondSource)) * signX
heightAdjustment = 0
}
if (j0x == null || j1x == null || heightAdjustment == null) return null
const attachmentHeight = inputs.targetPortTopDistanceInNode ?? 0
const top = Math.min(
inputs.targetOffset.y - MIN_APPROACH_HEIGHT - attachmentHeight + heightAdjustment,
inputs.targetOffset.y - theme.edge.min_approach_height - attachmentHeight + heightAdjustment,
0,
)
const source = new Vec2(sourceX, 0)
@ -297,7 +262,7 @@ function junctionPoints(inputs: Inputs): JunctionPoints | null {
const attachmentTarget = attachment?.target ?? inputs.targetOffset
return {
points: [source, j0, j1, attachmentTarget],
maxRadius: RADIUS_MAX,
maxRadius: radiusMax,
targetAttachment: attachment,
}
}
@ -440,12 +405,12 @@ const activeStyle = computed(() => {
const baseStyle = computed(() => ({ '--node-base-color': edgeColor.value ?? 'tan' }))
function click(_e: PointerEvent) {
if (base.value == null) return {}
if (navigator?.sceneMousePos == null) return {}
function click() {
if (base.value == null) return
if (navigator?.sceneMousePos == null) return
const length = base.value.getTotalLength()
let offset = lengthTo(navigator?.sceneMousePos)
if (offset == null) return {}
if (offset == null) return
if (offset < length / 2) graph.disconnectTarget(props.edge)
else graph.disconnectSource(props.edge)
}
@ -457,7 +422,7 @@ function arrowPosition(): Vec2 | undefined {
const target = targetRect.value
const source = sourceRect.value
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
return source.center().add(points[1])
}

View File

@ -1,4 +1,4 @@
<script setup lang="ts">
<script lang="ts">
import { nodeEditBindings } from '@/bindings'
import CircularMenu from '@/components/CircularMenu.vue'
import GraphNodeError from '@/components/GraphEditor/GraphNodeError.vue'
@ -12,12 +12,14 @@ import { injectGraphNavigator } from '@/providers/graphNavigator'
import { injectGraphSelection } from '@/providers/graphSelection'
import { useGraphStore, type Node } from '@/stores/graph'
import { useProjectStore } from '@/stores/project'
import { Ast } from '@/util/ast'
import { Prefixes } from '@/util/ast/prefixes'
import type { Opt } from '@/util/data/opt'
import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2'
import { displayedIconOf } from '@/util/getIconName'
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'
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. */
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<{
node: Node
edited: boolean
@ -39,10 +53,8 @@ const emit = defineEmits<{
outputPortClick: [portId: ExprId]
outputPortDoubleClick: [portId: ExprId]
doubleClick: []
'update:content': [updates: [range: ContentRange, content: string][]]
'update:edited': [cursorPosition: number]
'update:rect': [rect: Rect]
'update:selected': [selected: boolean]
'update:visualizationId': [id: Opt<VisualizationIdentifier>]
'update:visualizationRect': [rect: Rect | undefined]
'update:visualizationVisible': [visible: boolean]
@ -89,7 +101,6 @@ watch(isSelected, (selected) => {
menuVisible.value = menuVisible.value && selected
})
const isAutoEvaluationDisabled = ref(false)
const isDocsVisible = ref(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 outputPortLabel = computed(() => expressionInfo.value?.typename ?? 'Unknown')
const executionState = computed(() => expressionInfo.value?.payload.type ?? 'Unknown')
@ -223,6 +279,7 @@ const handleNodeClick = useDoubleClick(
(e: MouseEvent) => nodeEditHandler(e),
() => emit('doubleClick'),
).handleClick
interface PortData {
clipRange: [number, number]
label: string
@ -232,7 +289,7 @@ interface PortData {
const outputPorts = computed((): PortData[] => {
const ports = outputPortsSet.value
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 labelType =
graph.db.getExpressionInfo(numPorts > 1 ? portId : nodeId.value)?.typename ?? 'Unknown'
@ -296,8 +353,9 @@ function portGroupStyle(port: PortData) {
</div>
<CircularMenu
v-if="menuVisible"
v-model:isAutoEvaluationDisabled="isAutoEvaluationDisabled"
v-model:isOutputContextOverridden="isOutputContextOverridden"
v-model:isDocsVisible="isDocsVisible"
:isOutputContextEnabledGlobally="projectStore.isOutputContextEnabled"
:isVisualizationVisible="isVisualizationVisible"
@update:isVisualizationVisible="emit('update:visualizationVisible', $event)"
/>
@ -320,7 +378,7 @@ function portGroupStyle(port: PortData) {
<div class="node" @pointerdown="handleNodeClick" v-on="dragPointer.events">
<SvgIcon class="icon grab-handle" :name="icon"></SvgIcon>
<div ref="contentNode" class="widget-tree">
<NodeWidgetTree :ast="node.rootSpan" />
<NodeWidgetTree :ast="displayedExpression" />
</div>
</div>
<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 type { Vec2 } from '@/util/data/vec2'
import { stackItemsEqual } from 'shared/languageServerTypes'
import type { ContentRange, ExprId } from 'shared/yjsModel'
import type { ExprId } from 'shared/yjsModel'
import { computed, toRaw } from 'vue'
const projectStore = useProjectStore()
@ -23,14 +23,6 @@ const emit = defineEmits<{
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) {
const scaledOffset = offset.scale(1 / (navigator?.scale ?? 1))
dragging.startOrUpdate(movedId, scaledOffset)
@ -42,7 +34,7 @@ function hoverNode(id: ExprId | undefined) {
const uploadingFiles = computed<[FileName, File][]>(() => {
const currentStackItem = projectStore.executionContext.getStackTop()
return [...projectStore.awareness.allUploads()].filter(([_name, file]) =>
return [...projectStore.awareness.allUploads()].filter(([, file]) =>
stackItemsEqual(file.stackItem, toRaw(currentStackItem)),
)
})
@ -54,15 +46,13 @@ const uploadingFiles = computed<[FileName, File][]>(() => {
:key="id"
:node="node"
:edited="id === graphStore.editedNodeInfo?.id"
@delete="graphStore.deleteNode(id)"
@pointerenter="hoverNode(id)"
@pointerleave="hoverNode(undefined)"
@dragging="nodeIsDragged(id, $event)"
@draggingCommited="dragging.finishDrag()"
@outputPortClick="graphStore.createEdgeFromOutput"
@outputPortClick="graphStore.createEdgeFromOutput($event)"
@outputPortDoubleClick="emit('nodeOutputPortDoubleClick', $event)"
@doubleClick="emit('nodeDoubleClick', id)"
@update:content="updateNodeContent(id, $event)"
@update:edited="graphStore.setEditedNode(id, $event)"
@update:rect="graphStore.updateNodeRect(id, $event)"
@update:visualizationId="graphStore.setNodeVisualizationId(id, $event)"

View File

@ -20,7 +20,7 @@ const value = computed({
},
set(value) {
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,
})
const emit = defineEmits<{
(e: 'update:modelValue', toggledOn: boolean): void
'update:modelValue': [toggledOn: boolean]
}>()
</script>

View File

@ -273,7 +273,8 @@ export function usePointer(
trackedPointer.value = e.pointerId
// This is mostly SAFE, as virtually all `Element`s also extend `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)
lastPos = initialGrabPos
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 { Ast, RawAst, RawAstExtended } from '@/util/ast'
import { AliasAnalyzer } from '@/util/ast/aliasAnalysis'
import { nodeFromAst } from '@/util/ast/node'
import { colorFromString } from '@/util/colors'
import { MappedKeyMap, MappedSet } from '@/util/containers'
import { arrayEquals, byteArraysEqual, tryGetIndex } from '@/util/data/array'
import type { Opt } from '@/util/data/opt'
import { Vec2 } from '@/util/data/vec2'
import { ReactiveDb, ReactiveIndex, ReactiveMapping } from '@/util/database/reactiveDb'
import * as random from 'lib0/random'
import * as set from 'lib0/set'
import { methodPointerEquals, type MethodCall } from 'shared/languageServerTypes'
import {
@ -310,17 +312,19 @@ export class GraphDb {
return new GraphDb(db, ref([]), registry)
}
mockNode(binding: string, id: ExprId, code?: string) {
const node = {
mockNode(binding: string, id: ExprId, code?: string): Node {
const pattern = Ast.parse(binding)
const node: Node = {
outerExprId: id,
pattern: Ast.parse(binding),
pattern,
rootSpan: Ast.parse(code ?? '0'),
position: Vec2.Zero,
vis: undefined,
}
const bidingId = node.pattern.astId
const bindingId = pattern.astId
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>
}
function nodeFromAst(ast: Ast.Ast): Node {
const common = {
outerExprId: ast.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 mockNode(exprId?: ExprId): Node {
return {
outerExprId: exprId ?? (random.uuidv4() as ExprId),
pattern: undefined,
rootSpan: Ast.parse('0'),
position: Vec2.Zero,
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 {

View File

@ -156,13 +156,13 @@ export const useGraphStore = defineStore('graph', () => {
}
function disconnectSource(edge: Edge) {
if (edge.target)
unconnectedEdge.value = { target: edge.target, disconnectedEdgeTarget: edge.target }
if (!edge.target) return
unconnectedEdge.value = { target: edge.target, disconnectedEdgeTarget: edge.target }
}
function disconnectTarget(edge: Edge) {
if (edge.source && edge.target)
unconnectedEdge.value = { source: edge.source, disconnectedEdgeTarget: edge.target }
if (!edge.source || !edge.target) return
unconnectedEdge.value = { source: edge.source, disconnectedEdgeTarget: edge.target }
}
function clearUnconnected() {
@ -230,20 +230,6 @@ export const useGraphStore = defineStore('graph', () => {
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) {
const node = db.nodeIdToNode.get(nodeId)
if (!node) return
@ -338,8 +324,6 @@ export const useGraphStore = defineStore('graph', () => {
deleteNode,
setNodeContent,
setExpressionContent,
replaceNodeSubexpression,
replaceExpressionContent,
setNodePosition,
setNodeVisualizationId,
setNodeVisualizationVisible,

View File

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

View File

@ -367,7 +367,7 @@ const parseCases = [
]
test.each(parseCases)('parse: %s', (testCase) => {
const root = Ast.parse(testCase.code)
expect(Ast.debug(root)).toEqual(testCase.tree)
expect(Ast.tokenTree(root)).toEqual(testCase.tree)
})
// 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 { parseEnso } from '@/util/ast'
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 type { LazyObject } from '@/util/parserSupport'
import { unsafeEntries } from '@/util/record'
import * as random from 'lib0/random'
import { IdMap, type ExprId } from 'shared/yjsModel'
import { reactive } from 'vue'
interface Module {
export interface Module {
get raw(): MutableModule
get(id: AstId): Ast | null
getExtended(id: AstId): RawAstExtended | undefined
edit(): MutableModule
apply(module: MutableModule): void
}
class Committed implements Module {
nodes: Map<AstId, Ast>
astExtended: Map<AstId, RawAstExtended>
export class MutableModule implements Module {
base: Module | null
nodes: Map<AstId, Ast | null>
astExtended: Map<AstId, RawAstExtended> | null
constructor() {
this.nodes = reactive(new Map<AstId, Ast>())
this.astExtended = reactive(new Map<AstId, RawAstExtended>())
}
/** 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) {
constructor(
base: Module | null,
nodes: Map<AstId, Ast | null>,
astExtended: Map<AstId, RawAstExtended> | null,
) {
this.base = base
this.pending = new Map()
this.nodes = nodes
this.astExtended = astExtended
}
/** Replace all committed values with the state of the uncommitted parse. */
commit() {
for (const [id, ast] of this.pending.entries()) {
static Observable(): MutableModule {
const nodes = reactive(new Map<AstId, Ast>())
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) {
this.base.nodes.delete(id)
this.nodes.delete(id)
} 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. */
get(id: AstId): Ast | null {
const editedNode = this.pending.get(id)
const editedNode = this.nodes.get(id)
if (editedNode === null) {
return null
} else {
return editedNode ?? this.base.get(id) ?? null
return editedNode ?? this.base?.get(id) ?? null
}
}
set(id: AstId, ast: Ast) {
this.pending.set(id, ast)
this.nodes.set(id, ast)
}
getExtended(id: AstId): RawAstExtended | undefined {
return this.base.astExtended.get(id)
return this.astExtended?.get(id) ?? this.base?.getExtended(id)
}
delete(id: AstId) {
this.pending.set(id, null)
this.nodes.set(id, null)
}
}
@ -102,13 +117,17 @@ function newTokenId(): TokenId {
}
export class Token {
private _code: string
code_: string
exprId: TokenId
readonly _tokenType: RawAst.Token.Type
constructor(code: string, id: TokenId, type: RawAst.Token.Type) {
this._code = code
tokenType_: RawAst.Token.Type | undefined
constructor(code: string, id: TokenId, type: RawAst.Token.Type | undefined) {
this.code_ = code
this.exprId = id
this._tokenType = type
this.tokenType_ = type
}
static new(code: string) {
return new Token(code, newTokenId(), undefined)
}
// Compatibility wrapper for `exprId`.
@ -117,18 +136,19 @@ export class Token {
}
code(): string {
return this._code
return this.code_
}
typeName(): string {
return RawAst.Token.typeNames[this._tokenType]!
if (this.tokenType_) return RawAst.Token.typeNames[this.tokenType_]!
else return 'Raw'
}
}
export abstract class Ast {
readonly treeType: RawAst.Tree.Type | undefined
_id: AstId
readonly module: Committed
readonly module: Module
// Deprecated interface for incremental integration of Ast API. Eliminate usages for #8367.
get astExtended(): RawAstExtended | undefined {
@ -140,16 +160,12 @@ export abstract class Ast {
}
static deserialize(serialized: string): Ast {
const parsed: any = JSON.parse(serialized)
const nodes: NodeSpanMap = new Map(Object.entries(parsed.info.nodes))
const tokens: TokenSpanMap = new Map(Object.entries(parsed.info.tokens))
const module = new Committed()
const edit = new Edit(module)
const parsed: SerializedPrintedSource = JSON.parse(serialized)
const nodes = new Map(unsafeEntries(parsed.info.nodes))
const tokens = new Map(unsafeEntries(parsed.info.tokens))
const module = MutableModule.Transient()
const tree = parseEnso(parsed.code)
type NodeSpanMap = Map<NodeKey, AstId[]>
type TokenSpanMap = Map<TokenKey, TokenId>
const root = abstract(edit, tree, parsed.code, { nodes, tokens }).node
edit.commit()
const root = abstract(module, tree, parsed.code, { nodes, tokens }).node
return module.get(root)!
}
@ -176,8 +192,8 @@ export abstract class Ast {
/** Returns child subtrees, including information about the whitespace between them. */
abstract concreteChildren(): IterableIterator<NodeChild>
code(): string {
return print(this).code
code(module?: Module): string {
return print(this, module).code
}
repr(): string {
@ -189,17 +205,25 @@ export abstract class Ast {
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 ids = typeof source === 'object' ? source.info : undefined
const tree = parseEnso(code)
const module = new Committed()
const edit = new Edit(module)
const newRoot = abstract(edit, tree, code, ids).node
edit.commit()
const module = inModule ?? MutableModule.Observable()
const newRoot = abstract(module, tree, code, ids).node
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) {
visit(this)
for (const child of this.concreteChildren()) {
@ -211,21 +235,23 @@ export abstract class Ast {
}
}
protected constructor(module: Edit, id?: AstId, treeType?: RawAst.Tree.Type) {
this.module = module.base
protected constructor(module: MutableModule, id?: AstId, treeType?: RawAst.Tree.Type) {
this.module = module
this._id = id ?? newNodeId()
this.treeType = treeType
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 = ''
for (const child of this.concreteChildren()) {
if (
child.node != null &&
!(child.node instanceof Token) &&
this.module.get(child.node) === null
)
if (child.node != null && !(child.node instanceof Token) && module_.get(child.node) === null)
continue
if (child.whitespace != null) {
code += child.whitespace
@ -237,7 +263,9 @@ export abstract class Ast {
if (child.node instanceof Token) {
code += child.node.code()
} 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 {
private _func: NodeChild<AstId>
private _leftParen: NodeChild<Token> | null
private _argumentName: NodeChild<Token> | null
private _equals: NodeChild<Token> | null
private _arg: NodeChild<AstId>
private _rightParen: NodeChild<Token> | null
_func: NodeChild<AstId>
_leftParen: NodeChild<Token> | null
_argumentName: NodeChild<Token> | null
_equals: NodeChild<Token> | null
_arg: NodeChild<AstId>
_rightParen: NodeChild<Token> | null
get function(): Ast {
return this.module.get(this._func.node)!
@ -273,7 +301,7 @@ export class App extends Ast {
}
constructor(
module: Edit,
module: MutableModule,
id: AstId | undefined,
func: NodeChild<AstId>,
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(
module: Edit,
module: MutableModule,
id: AstId | undefined,
func: NodeChild<AstId>,
arg: NodeChild<AstId>,
@ -313,7 +359,7 @@ function positionalApp(
}
function namedApp(
module: Edit,
module: MutableModule,
id: AstId | undefined,
func: NodeChild<AstId>,
leftParen: NodeChild<Token> | null,
@ -336,8 +382,8 @@ function namedApp(
}
export class UnaryOprApp extends Ast {
private _opr: NodeChild<Token>
private _arg: NodeChild<AstId> | null
_opr: NodeChild<Token>
_arg: NodeChild<AstId> | null
get operator(): Token {
return this._opr.node
@ -349,7 +395,7 @@ export class UnaryOprApp extends Ast {
}
constructor(
module: Edit,
module: MutableModule,
id: AstId | undefined,
opr: NodeChild<Token>,
arg: NodeChild<AstId> | null,
@ -367,7 +413,7 @@ export class UnaryOprApp extends Ast {
export class NegationOprApp extends UnaryOprApp {
constructor(
module: Edit,
module: MutableModule,
id: AstId | undefined,
opr: NodeChild<Token>,
arg: NodeChild<AstId> | null,
@ -377,9 +423,9 @@ export class NegationOprApp extends UnaryOprApp {
}
export class OprApp extends Ast {
protected _lhs: NodeChild<AstId> | null
protected _opr: NodeChild[]
protected _rhs: NodeChild<AstId> | null
_lhs: NodeChild<AstId> | null
_opr: NodeChild[]
_rhs: NodeChild<AstId> | null
get lhs(): Ast | null {
return this._lhs ? this.module.get(this._lhs.node) : null
@ -399,7 +445,7 @@ export class OprApp extends Ast {
}
constructor(
module: Edit,
module: MutableModule,
id: AstId | undefined,
lhs: NodeChild<AstId> | null,
opr: NodeChild[],
@ -420,7 +466,7 @@ export class OprApp extends Ast {
export class PropertyAccess extends OprApp {
constructor(
module: Edit,
module: MutableModule,
id: AstId | undefined,
lhs: NodeChild<AstId> | null,
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. */
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)
this._children = children ?? []
}
@ -447,39 +498,39 @@ export class Generic extends Ast {
type MultiSegmentAppSegment = { header: NodeChild<Token>; body: NodeChild<AstId> | null }
export class Import extends Ast {
private polyglot_: MultiSegmentAppSegment | null
private from_: MultiSegmentAppSegment | null
private import__: MultiSegmentAppSegment
private all_: NodeChild<Token> | null
private as_: MultiSegmentAppSegment | null
private hiding_: MultiSegmentAppSegment | null
_polyglot: MultiSegmentAppSegment | null
_from: MultiSegmentAppSegment | null
_import: MultiSegmentAppSegment
_all: NodeChild<Token> | null
_as: MultiSegmentAppSegment | null
_hiding: MultiSegmentAppSegment | 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 {
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 {
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 {
return this.all_?.node ?? null
return this._all?.node ?? 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 {
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(
module: Edit,
module: MutableModule,
id: AstId | undefined,
polyglot: MultiSegmentAppSegment | null,
from: MultiSegmentAppSegment | null,
@ -489,12 +540,12 @@ export class Import extends Ast {
hiding: MultiSegmentAppSegment | null,
) {
super(module, id, RawAst.Tree.Type.Import)
this.polyglot_ = polyglot
this.from_ = from
this.import__ = import_
this.all_ = all
this.as_ = as
this.hiding_ = hiding
this._polyglot = polyglot
this._from = from
this._import = import_
this._all = all
this._as = as
this._hiding = hiding
}
*concreteChildren(): IterableIterator<NodeChild> {
@ -504,23 +555,23 @@ export class Import extends Ast {
if (segment?.body) parts.push(segment.body)
return parts
}
yield* segment(this.polyglot_)
yield* segment(this.from_)
yield* segment(this.import__)
if (this.all_) yield this.all_
yield* segment(this.as_)
yield* segment(this.hiding_)
yield* segment(this._polyglot)
yield* segment(this._from)
yield* segment(this._import)
if (this._all) yield this._all
yield* segment(this._as)
yield* segment(this._hiding)
}
}
export class TextLiteral extends Ast {
private readonly open_: NodeChild<Token> | null
private readonly newline_: NodeChild<Token> | null
private readonly elements_: NodeChild[]
private readonly close_: NodeChild<Token> | null
_open: NodeChild<Token> | null
_newline: NodeChild<Token> | null
_elements: NodeChild[]
_close: NodeChild<Token> | null
constructor(
module: Edit,
module: MutableModule,
id: AstId | undefined,
open: NodeChild<Token> | null,
newline: NodeChild<Token> | null,
@ -528,62 +579,70 @@ export class TextLiteral extends Ast {
close: NodeChild<Token> | null,
) {
super(module, id, RawAst.Tree.Type.TextLiteral)
this.open_ = open
this.newline_ = newline
this.elements_ = elements
this.close_ = close
this._open = open
this._newline = newline
this._elements = elements
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> {
if (this.open_) yield this.open_
if (this.newline_) yield this.newline_
yield* this.elements_
if (this.close_) yield this.close_
if (this._open) yield this._open
if (this._newline) yield this._newline
yield* this._elements
if (this._close) yield this._close
}
}
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)
this.expression_ = expression
this._expression = expression
}
*concreteChildren(): IterableIterator<NodeChild> {
yield this.expression_
yield this._expression
}
}
export class Group extends Ast {
private readonly open_: NodeChild<Token> | undefined
private readonly expression_: NodeChild<AstId> | null
private readonly close_: NodeChild<Token> | undefined
_open: NodeChild<Token> | undefined
_expression: NodeChild<AstId> | null
_close: NodeChild<Token> | undefined
constructor(
module: Edit,
module: MutableModule,
id: AstId | undefined,
open: NodeChild<Token> | undefined,
expression: NodeChild<AstId> | null,
close: NodeChild<Token> | undefined,
) {
super(module, id, RawAst.Tree.Type.Group)
this.open_ = open
this.expression_ = expression
this.close_ = close
this._open = open
this._expression = expression
this._close = close
}
*concreteChildren(): IterableIterator<NodeChild> {
if (this.open_) yield this.open_
if (this.expression_) yield this.expression_
if (this.close_) yield this.close_
if (this._open) yield this._open
if (this._expression) yield this._expression
if (this._close) yield this._close
}
}
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)
this._tokens = tokens ?? []
}
@ -594,11 +653,12 @@ export class NumericLiteral extends Ast {
}
type FunctionArgument = NodeChild[]
export class Function extends Ast {
private _name: NodeChild<AstId>
private _args: FunctionArgument[]
private _equals: NodeChild<Token>
private _body: NodeChild<AstId> | null
_name: NodeChild<AstId>
_args: FunctionArgument[]
_equals: NodeChild<Token>
_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
// here and in `rawChildren` (and indirectly, `print`).
get name(): Ast | null {
@ -616,7 +676,7 @@ export class Function extends Ast {
}
}
constructor(
module: Edit,
module: MutableModule,
id: AstId | undefined,
name: NodeChild<AstId>,
args: FunctionArgument[],
@ -640,9 +700,9 @@ export class Function extends Ast {
}
export class Assignment extends Ast {
private _pattern: NodeChild<AstId>
private _equals: NodeChild<Token>
private _expression: NodeChild<AstId>
_pattern: NodeChild<AstId>
_equals: NodeChild<Token>
_expression: NodeChild<AstId>
get pattern(): Ast | null {
return this.module.get(this._pattern.node)
}
@ -650,7 +710,7 @@ export class Assignment extends Ast {
return this.module.get(this._expression.node)
}
constructor(
module: Edit,
module: MutableModule,
id: AstId | undefined,
pattern: NodeChild<AstId>,
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
expression: NodeChild<AstId> | null
}
export class BodyBlock extends Ast {
private _lines: BlockLine[];
_lines: BlockLine[];
*expressions(): IterableIterator<Ast> {
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)
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 = ''
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?.node.code() ?? '\n'
if (line.expression !== null) {
code += line.expression.whitespace ?? indent
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 {
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)
this.token = token
}
@ -777,18 +846,16 @@ export class Ident extends Ast {
export class Wildcard extends Ast {
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)
this.token = token
}
static new(): Wildcard {
const module = new Committed()
const edit = new Edit(module)
const ast = new Wildcard(edit, undefined, {
const module = MutableModule.Transient()
const ast = new Wildcard(module, undefined, {
node: new Token('_', newTokenId(), RawAst.Token.Type.Wildcard),
})
edit.commit()
return ast
}
@ -798,9 +865,9 @@ export class Wildcard 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)
this._code = code
}
@ -819,7 +886,7 @@ export class RawCode extends Ast {
}
function abstract(
module: Edit,
module: MutableModule,
tree: RawAst.Tree,
code: string,
info: InfoMap | undefined,
@ -830,8 +897,9 @@ function abstract(
const tokenIds = info?.tokens ?? new Map()
return abstractTree(module, tree, code, nodesExpected, tokenIds)
}
function abstractTree(
module: Edit,
module: MutableModule,
tree: RawAst.Tree,
code: string,
nodesExpected: NodeSpanMap,
@ -858,8 +926,9 @@ function abstractTree(
const whitespaceEnd = whitespaceStart + tree.whitespaceLengthInCodeParsed
const codeStart = whitespaceEnd
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
// must pop the tree's span from the ID map *after* processing children.
// All node types use this value in the same way to obtain the ID type,
// 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)
let node: AstId
switch (tree.type) {
@ -1030,54 +1099,68 @@ function abstractToken(
return { whitespace, node }
}
type NodeKey = string
type TokenKey = string
function nodeKey(start: number, length: number, type: RawAst.Tree.Type | undefined): NodeKey {
declare const nodeKeyBrand: unique symbol
type NodeKey = string & { [nodeKeyBrand]: never }
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() ?? '?'
return `${start}:${length}:${type_}`
return `${start}:${length}:${type_}` as NodeKey
}
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 TokenSpanMap = Map<TokenKey, TokenId>
export type InfoMap = {
export interface InfoMap {
nodes: NodeSpanMap
tokens: TokenSpanMap
}
type PrintedSource = {
interface PrintedSource {
info: InfoMap
code: string
}
/** 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 = {
nodes: new Map(),
tokens: new Map(),
}
const code = ast._print(info, 0, '')
const code = ast._print(info, 0, '', module)
return { info, code }
}
type DebugTree = (DebugTree | string)[]
export function debug(root: Ast, universe?: Map<AstId, Ast>): DebugTree {
export type TokenTree = (TokenTree | string)[]
export function tokenTree(root: Ast): TokenTree {
const module = root.module
return Array.from(root.concreteChildren(), (child) => {
if (child.node instanceof Token) {
return child.node.code()
} else {
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.
export function findModuleMethod(module: Committed, name: string): Function | null {
for (const node of module.nodes.values()) {
export function findModuleMethod(module: Module, name: string): Function | null {
for (const node of module.raw.nodes.values()) {
if (node instanceof Function) {
if (node.name && node.name.code() === name) {
return node
@ -1087,7 +1170,7 @@ export function findModuleMethod(module: Committed, name: string): Function | nu
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)
if (!method || !(method.body instanceof BodyBlock)) return null
return method.body
@ -1120,7 +1203,7 @@ export function parseTransitional(code: string, idMap: IdMap): Ast {
idMap.finishAndSynchronize()
const nodes = new Map<NodeKey, AstId[]>()
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>) => {
const start = 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
})
const newRoot = Ast.parse({ info: { nodes, tokens }, code })
newRoot.module.astExtended = astExtended
newRoot.module.raw.astExtended = astExtended
return newRoot
}
export function parse(source: PrintedSource | string): Ast {
return Ast.parse(source)
}
export const parse = Ast.parse
export const parseLine = Ast.parseLine
export function deserialize(serialized: string): Ast {
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]> {
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
}
if (leftResult.done || rightResult.done) break
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
this.taskRunning = true
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(() => {
this.taskRunning = false
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,
"vertical_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",
"shared/**/*",
"shared/**/*.vue",
"src/util/theme.json"
"src/util/theme.json",
"stories/mockSuggestions.json",
"mock/**/*"
],
"exclude": ["src/**/__tests__/*", "shared/**/__tests__/*", "public/**/__tests__/*"],
"compilerOptions": {
@ -15,6 +17,7 @@
"composite": true,
"outDir": "../../node_modules/.cache/tsc",
"baseUrl": ".",
"noEmit": true,
"allowImportingTsExtensions": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,

View File

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