diff --git a/flatn/flatn-cjs.js b/flatn/flatn-cjs.js index 18eb39469f..27a01c3b6d 100644 --- a/flatn/flatn-cjs.js +++ b/flatn/flatn-cjs.js @@ -9216,7 +9216,8 @@ function print (object, quote = '\"') { if (object && Array.isArray(object)) { return '[' + object.map(print) + ']'; } if (typeof object !== 'string') { return String(object); } let result = String(object); - result = result.replace(/\n/g, '\\n\\\n'); + result = result.replace(/\\/g, '\\\\'); + result = result.replace(/\n/g, '\\n'); result = result.replace(/(")/g, '\\$1'); result = result.replace(/(')/g, '\\$1'); result = quote + result + quote; diff --git a/lively.halos/morph.js b/lively.halos/morph.js index 1a089290f4..50acd94509 100644 --- a/lively.halos/morph.js +++ b/lively.halos/morph.js @@ -442,8 +442,8 @@ class NameHolder extends Morph { this.validName = !owner || !owner.getSubmorphNamed(newName) || oldName === newName; if (this.target.isComponent && !evt.hasArrowPressed) { // also confirm we are not in conflict with other stuff in the module scope - System.import('lively.ide/components/reconciliation.js').then(({ canBeRenamed }) => { - this.validName = this.validName && canBeRenamed(moduleManager.module(this.target[Symbol.for('lively-module-meta')].moduleId), oldName, newName); + System.import('lively.ide/components/component-definition.js').then(({ canBeRenamed }) => { + this.validName = this.validName && canBeRenamed(moduleManager.module(this.target[Symbol.for('lively-module-meta')].moduleId), newName); signal(this, 'valid', [this.validName, newName]); }); return; @@ -1193,7 +1193,7 @@ class ComponentHaloItem extends RoundHaloItem { const { insertComponentDefinition, removeComponentDefinition - } = await System.import('lively.ide/components/reconciliation.js'); + } = await System.import('lively.ide/components/component-definition.js'); const { InteractiveComponentDescriptor } = await System.import('lively.ide/components/editor.js'); const Browser = await System.import('lively.ide/js/browser/ui.cp.js'); if (toBeComponent) { diff --git a/lively.ide/components/change-tracker.js b/lively.ide/components/change-tracker.js index 0b710b2f9d..b198048ddd 100644 --- a/lively.ide/components/change-tracker.js +++ b/lively.ide/components/change-tracker.js @@ -1,7 +1,326 @@ -import { obj, promise } from 'lively.lang'; +import { obj, string } from 'lively.lang'; import module from 'lively.modules/src/module.js'; +import { ExpressionSerializer } from 'lively.serializer2'; import { connect } from 'lively.bindings'; -import { Reconciliation } from './reconciliation.js'; +import { morph } from 'lively.morphic'; +import { + MorphicAttachmentKind, + MorphicOperationKind, + MorphicValueSemantics +} from 'lively.morphic/changes/index.js'; +import { CompositeEditTransaction } from 'lively.morphic/undo.js'; +import { getTextAttributesExpr, getValueExpr } from './helpers.js'; +import { + ClearPropertyOverride, + SetOpaqueProperty, + SetProperty +} from './reconciliation/commands.js'; +import { + ComponentImportKind, + componentImportBindingsFromExpression +} from './reconciliation/import-bindings.js'; +import { + layoutPropertyCannotReferenceChildren, + parseComponentSource +} from './reconciliation/source-adapter.js'; +import { serializeRuntimeComponentNode } from './reconciliation/runtime-node-serializer.js'; +import { + ComponentDocument, + ComponentNode, + ComponentPropertyKind, + findComponentLayoutModel, + findComponentNode, + findComponentParent, + localNodeProvenance, + sourceComponentReference +} from './reconciliation/component-document.js'; +import { + ComponentBridgeCommandKind, + MorphicChangeSetAdapter +} from './reconciliation/morphic-change-set-adapter.js'; +import { + ShadowProjectionComparisonKind, + compareShadowProjectionToCurrentSource, + prepareShadowScalarProjection +} from './reconciliation/shadow-projection.js'; +import { + ComponentTransactionDirection, + ComponentRuntimeCommitMode, + ProjectionalComponentEditTransaction, + applyPreparedComponentTransaction, + commitPreparedComponentTransaction, + prepareScalarComponentTransaction, + preparedComponentTransactionFromShadowProjection +} from './reconciliation/component-transaction.js'; +import { + planDerivedComponentRenamePropagation, + planDerivedComponentStructurePropagation +} from './reconciliation/derived-projector.js'; +import { + DerivedRuntimeStructureProjectionKind, + projectCachedDerivedRuntimeStructure +} from './reconciliation/derived-runtime-projector.js'; +import { + DerivedTransactionDirection, + PreparedDerivedPropagationTransaction, + PreparedDerivedRuntimeChangeTransaction, + PreparedDerivedRuntimeRenameTransaction, + ProjectionalDerivedEditTransaction, + ProjectionalDerivedRuntimeChangeEditTransaction, + ProjectionalDerivedRuntimeEditTransaction, + applyPreparedDerivedPropagation, + applyPreparedDerivedRuntimeChanges, + applyPreparedDerivedRuntimeRenames, + validatePreparedDerivedPropagation, + validatePreparedDerivedRuntimeChanges, + validatePreparedDerivedRuntimeRenames +} from './reconciliation/derived-transaction.js'; +import { + PolicyCacheTransactionDirection, + PreparedPolicyCachePropertyTransaction, + PreparedPolicyCacheRenameTransaction, + ProjectionalPolicyCacheEditTransaction, + ProjectionalPolicyCachePropertyEditTransaction, + applyPreparedPolicyCacheProperties, + applyPreparedPolicyCacheRenames, + validatePreparedPolicyCacheProperties, + validatePreparedPolicyCacheRenames +} from './reconciliation/policy-cache-transaction.js'; + +const derivedExpressionSerializer = new ExpressionSerializer(); +const moduleReconciliationStates = new WeakMap(); + +function moduleReconciliationStateFor (tracker) { + const componentModule = tracker.componentModule; + if (!componentModule || (typeof componentModule !== 'object' && + typeof componentModule !== 'function')) return null; + let state = moduleReconciliationStates.get(componentModule); + if (!state) { + state = { pending: null, completion: null }; + moduleReconciliationStates.set(componentModule, state); + } + return state; +} + +function componentNodeNamePath (document, nodeId) { + const visit = (node, path) => { + if (node.id === nodeId) return path; + for (const child of node.children) { + const found = visit(child, path.concat(child.name)); + if (found) return found; + } + return null; + }; + return visit(document.root, []); +} + +function policySpecProperties (spec) { + if (spec?.isPolicy) return spec.spec; + if (spec?.COMMAND === 'add') return spec.props; + return spec?.props || spec; +} + +function policySpecAtPath (policy, path) { + let current = policy; + for (const name of path) { + const properties = policySpecProperties(current); + current = (properties?.submorphs || []).find(spec => + policySpecProperties(spec)?.name === name + ); + if (!current) { + try { + return policy.getSubSpecAt?.(path.slice()) || null; + } catch (error) { + return null; + } + } + } + return current; +} + +function effectivePolicyLayoutAtPath (policy, path) { + const visited = new Set(); + const layoutInPolicyChain = candidate => { + for (let current = candidate; + current && !visited.has(current); + current = current.isPolicy ? current.parent : null) { + visited.add(current); + const properties = policySpecProperties(current); + if (Object.prototype.hasOwnProperty.call(properties || {}, 'layout')) { + return Object.freeze({ found: true, layout: properties.layout }); + } + } + return Object.freeze({ found: false, layout: null }); + }; + + for (let current = policy; + current && !visited.has(current); + current = current.parent) { + const candidate = path.length + ? policySpecAtPath(current, path) + : current.spec || current; + const result = layoutInPolicyChain(candidate); + if (result.found) return result.layout; + } + return null; +} + +function projectionalPolicyTextAndAttributes (textAndAttributes) { + return textAndAttributes.map(value => value?.isMorph + ? { ...value.spec(), __isSpec__: true } + : value); +} + +function projectionalExpressionBindings (bindings) { + const result = {}; + for (const binding of bindings || []) { + const references = result[binding.moduleId] ||= []; + if (binding.kind === ComponentImportKind.NAMED && + binding.imported === binding.local) { + references.push(binding.imported); + } else { + references.push({ + exported: binding.kind === ComponentImportKind.NAMED + ? binding.imported + : binding.kind === ComponentImportKind.DEFAULT ? 'default' : '*', + local: binding.local + }); + } + } + return result; +} + +function projectionalMaterializedProperties (node, bindings) { + const expressionBindings = projectionalExpressionBindings(bindings); + return Object.fromEntries(Object.entries(node?.properties || {}).flatMap( + ([property, entry]) => { + if (entry.kind === ComponentPropertyKind.EXPLICIT_VALUE) { + return [[property, entry.value]]; + } + try { + return [[property, derivedExpressionSerializer.deserializeExprObj({ + __expr__: entry.expression, + bindings: expressionBindings + })]]; + } catch (error) { + return []; + } + } + )); +} + +function materializeProjectionalTextAndAttributes (textAndAttributes) { + return textAndAttributes.map(value => { + if (value?.__isSpec__) return morph(value); + if (value?.isPolicy && typeof value.instantiate === 'function') { + return value.instantiate(); + } + if (value?.isMorph && typeof value.copy === 'function') return value.copy(); + return value; + }); +} + +function projectionalTextValueMatches (current, expected) { + if (!Array.isArray(current) || current.length !== expected.length) return false; + return current.every((value, index) => { + const expectedValue = expected[index]; + if (value?.isMorph && expectedValue?.__isSpec__) { + const expectedSpec = { ...expectedValue }; + delete expectedSpec.__isSpec__; + return obj.equals(value.spec(), expectedSpec); + } + if (value?.isMorph && expectedValue?.isMorph) { + return obj.equals(value.spec(), expectedValue.spec()); + } + return obj.equals(value, expectedValue); + }); +} + +function isProjectionalExplicitValue (value) { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return true; + if (typeof value === 'number') return Number.isFinite(value); + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index++) { + if (!(index in value) || !isProjectionalExplicitValue(value[index])) return false; + } + return true; + } + return !!value && Object.getPrototypeOf(value) === Object.prototype && + Object.values(value).every(isProjectionalExplicitValue); +} + +export const ProjectionalCommandDiagnosticKind = Object.freeze({ + SOURCE_UNSUPPORTED: 'source-unsupported', + TARGET_UNRESOLVED: 'target-unresolved', + UNDO_UNAVAILABLE: 'undo-unavailable', + PLANNING_FAILED: 'planning-failed' +}); + +export class ProjectionalReconciliationUnsupportedError extends Error { + constructor (change, batch = null) { + const diagnostics = [ + ...(batch?.diagnostics || []), + ...(batch?.shadowProjection?.diagnostics || []), + batch?.renameDiagnostic, + batch?.commitDiagnostic + ].filter(Boolean); + const reason = diagnostics.map(({ message, kind }) => message || kind).join('; ') || + 'No projectional command committed the change'; + const message = `Projectional reconciliation does not support this change: ${reason}`; + super(message); + this.name = 'ProjectionalReconciliationUnsupportedError'; + this.message = message; + this.change = change; + this.batch = batch; + this.diagnostics = Object.freeze(diagnostics); + } +} + +export const ProjectionalRenameDiagnosticKind = Object.freeze({ + DERIVED_DEPENDANTS: 'derived-dependants', + OWNER_LAYOUT: 'owner-layout' +}); + +export const ProjectionalStructuralDiagnosticKind = Object.freeze({ + DERIVED_DEPENDANTS: 'derived-dependants' +}); + +const ProjectionalPolicyCacheProjectionKind = Object.freeze({ + RENAME: 'rename', + PROPERTY: 'property' +}); + +function unsupportedProjectionalCommand (kind, message, details = {}) { + return Object.freeze({ + committed: false, + diagnostics: Object.freeze([Object.freeze({ kind, message, ...details })]) + }); +} + +export function componentChangeTrackerFor (morph) { + for (let current = morph; current; current = current.owner) { + const tracker = current._changeTracker; + if (tracker && (typeof tracker.tracksMorph !== 'function' || tracker.tracksMorph(morph))) { + return tracker; + } + } + return null; +} + +export function setMorphPropertyWithComponentCommand (options = {}) { + const { target, property, value } = options; + if (!target || typeof property !== 'string' || !property || + !Object.prototype.hasOwnProperty.call(options, 'value')) { + throw new Error('Setting a morph property requires a target, property, and value'); + } + const tracker = componentChangeTrackerFor(target); + if (tracker) return tracker.setProperty(options); + + if (property in target) target[property] = value; + else if (typeof target.setProperty === 'function') target.setProperty(property, value); + else target[property] = value; + return null; +} /** * ComponentChangeTrackers listen for evals of the componet module @@ -19,9 +338,1894 @@ export class ComponentChangeTracker { this.componentDescriptor = descriptor; connect(aComponent, 'onSubmorphChange', this, 'processChangeInComponent', { garbageCollect: true }); connect(aComponent, 'onChange', this, 'processChangeInComponent', { garbageCollect: true }); + this.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: `${this.componentModuleId}::${aComponent.name}`, + containsMorph: morph => this.tracksMorph(morph), + ignoreOperation: (operation, context) => + this.ignoreCommittedTextOperation(operation, context) + }); + this._committedChangeListener = (changeSet, context) => + this.processCommittedChangeSet(changeSet, context); + aComponent.env.changeManager.addCommittedChangeListener(this._committedChangeListener); aComponent._changeTracker = this; } + tracksMorph (morph) { + return morph === this.trackedComponent || this.trackedComponent.isAncestorOf(morph); + } + + ignoreCommittedTextOperation (operation, context) { + const target = context.resolveMorph?.(operation.targetId); + if (operation.kind === MorphicOperationKind.SET_MORPH_PROPERTY) { + const isGroupedLayoutGeometry = + ['extent', 'position'].includes(operation.property) && + context.committedChange?.changes?.length && + context.legacyChanges?.some(change => change.prop === 'layout'); + // Layout application records the geometry it derives as ordinary + // property changes. The layout assignment itself is the semantic source + // edit; projecting these side effects as well makes grouped gestures + // order-dependent and creates stale preconditions. + if (operation.property !== 'layout' && + (operation.metadata?.isLayoutAction || + isGroupedLayoutGeometry)) return true; + // TextMorph derives these implementation properties while installing + // rich content. The semantic textAndAttributes operation owns the source + // projection; explicit command callers can still set needsDocument. + if (target?.isText && + ['document', 'textLayout', 'needsDocument'].includes(operation.property)) { + return true; + } + // Auto-fitting text records its derived extent as a layout action for + // static text and as a meta interaction for document-backed text. It may + // be recomputed before undo, so projecting it as an independent exact + // edit creates a stale extent precondition. Deliberate Text extent edits + // have neither internal marker and must continue through reconciliation. + if (target?.isText && operation.property === 'extent' && + (operation.metadata?.isLayoutAction || + operation.metadata?.metaInteraction)) return true; + return operation.property === 'position' && + target?.owner?.isText && + target.owner.textAndAttributes?.includes(target); + } + if (operation.kind !== MorphicOperationKind.MOVE_MORPH || !target) return false; + const owners = [operation.from.ownerId, operation.to.ownerId] + .map(id => id && context.resolveMorph?.(id)) + .filter(Boolean); + return owners.some(owner => owner.isText && + (owner.textAndAttributes?.includes(target) || target.owner === owner)); + } + + processCommittedChangeSet (changeSet, context) { + const result = this.committedChangeAdapter.adapt(changeSet, context); + if (!result.commands.length && !result.diagnostics.length) return result; + const shadowProjection = this.prepareShadowProjection(result.commands, context); + let batch = Object.freeze({ + changeSetId: changeSet.id, + origin: changeSet.origin, + commands: result.commands, + diagnostics: result.diagnostics, + shadowProjection + }); + const derivedPropagation = this.prepareProjectionalDerivedRename(batch) || + this.prepareProjectionalDerivedStructure(batch, context); + if (derivedPropagation) batch = Object.freeze({ ...batch, derivedPropagation }); + const policyCacheProjection = this.prepareProjectionalPolicyCacheProjection(batch, context); + if (policyCacheProjection) batch = Object.freeze({ ...batch, policyCacheProjection }); + const renameDiagnostic = this.projectionalRenameDiagnostic(batch, context); + if (renameDiagnostic) batch = Object.freeze({ ...batch, renameDiagnostic }); + const projectionalCommit = renameDiagnostic + ? null + : this.commitProjectionalBatch(batch, context); + if (projectionalCommit) { + batch = Object.freeze({ ...batch, projectionalCommit }); + } else { + const commitDiagnostic = renameDiagnostic || + this.projectionalCommitRejectionDiagnostic(batch, context); + if (commitDiagnostic) batch = Object.freeze({ ...batch, commitDiagnostic }); + // No unsupported projection becomes authoritative. Reparse on the next + // attempt instead of retaining a document this batch did not commit. + this._projectionalDocument = null; + this._projectionalSource = null; + } + this.shadowCommandBatches = (this.shadowCommandBatches || []).concat(batch).slice(-100); + this.lastShadowCommandBatch = batch; + this._projectionalBatchesByLegacyChange ||= new WeakMap(); + const batchChanges = [ + ...(context.legacyChanges || []), + context.committedChange + ].filter(Boolean); + for (const legacyChange of batchChanges) { + this._projectionalBatchesByLegacyChange.set(legacyChange, batch); + } + this.scheduleShadowProjectionComparison(batch); + this.onShadowComponentCommands?.(batch); + return Object.freeze({ + ...result, + shadowProjection, + projectionalCommit: batch.projectionalCommit || null + }); + } + + projectionalCommitRejectionDiagnostic (batch, context) { + if (batch.diagnostics.length) return null; + const allowedKinds = [ + ComponentBridgeCommandKind.SET_PROPERTY, + ComponentBridgeCommandKind.SET_MASTER, + ComponentBridgeCommandKind.EDIT_TEXT, + ComponentBridgeCommandKind.RENAME_NODE, + ComponentBridgeCommandKind.INTRODUCE_NODE, + ComponentBridgeCommandKind.REMOVE_NODE, + ComponentBridgeCommandKind.MOVE_NODE + ]; + const isMultiScalarBatch = batch.commands.length > 1 && + batch.commands.every(command => [ + ComponentBridgeCommandKind.SET_PROPERTY, + ComponentBridgeCommandKind.SET_MASTER, + ComponentBridgeCommandKind.EDIT_TEXT, + ComponentBridgeCommandKind.RENAME_NODE + ].includes(command.kind)) && + !(this.componentDescriptor?.stylePolicy?._dependants?.size > 0) && + !batch.derivedPropagation && + !batch.policyCacheProjection; + if (!isMultiScalarBatch && + (batch.commands.length !== 1 || + !allowedKinds.includes(batch.commands[0]?.kind))) { + return Object.freeze({ + kind: ProjectionalCommandDiagnosticKind.PLANNING_FAILED, + message: `The committed change produced ${batch.commands.length} projectional commands` + }); + } + const projectionalLegacyChangeCount = + this.projectionalLegacyChangeCount(context); + if (projectionalLegacyChangeCount !== batch.commands.length) { + return Object.freeze({ + kind: ProjectionalCommandDiagnosticKind.PLANNING_FAILED, + message: `The committed change contains ${projectionalLegacyChangeCount} projectional legacy changes for ${batch.commands.length} projectional commands` + }); + } + const ownedLegacyChanges = this.projectionallyOwnedLegacyChanges(batch, context); + if (!this.canRecordProjectionalEdit(ownedLegacyChanges)) { + return Object.freeze({ + kind: ProjectionalCommandDiagnosticKind.UNDO_UNAVAILABLE, + message: 'The projectional edit cannot safely replace the active Morphic undo records' + }); + } + if (!batch.shadowProjection?.supported) return null; + return Object.freeze({ + kind: ProjectionalCommandDiagnosticKind.PLANNING_FAILED, + message: 'The projectional batch passed preflight but did not commit' + }); + } + + projectionallyOwnedLegacyChanges (batch, context) { + const legacyChanges = context.legacyChanges || []; + const committedChanges = context.committedChange && + !legacyChanges.includes(context.committedChange) + ? [...legacyChanges, context.committedChange] + : legacyChanges; + const command = batch.commands[0]; + if (batch.commands.length !== 1 || + command?.kind !== ComponentBridgeCommandKind.EDIT_TEXT) { + return committedChanges; + } + + const undoManager = this.trackedComponent?.env?.undoManager; + const recorded = undoManager?.undoInProgress?.recorder?.changes; + const target = context.resolveMorph?.(command.sourceOperation.targetId); + if (!Array.isArray(recorded) || !target) return committedChanges; + + // Document-backed TextMorphs implement a textAndAttributes assignment via + // replace(). The change engine records both the semantic property change + // and a legacy MethodCallChange wrapper (plus the wrapper's derived leaf + // changes). The component transaction must own all of them; retaining the + // wrapper would apply the text inverse twice during undo. + const replacementWrappers = recorded.filter(change => + change.target === target && + change.selector === 'replace' && + obj.equals(change.meta?.prevTextAndAttributes, command.previousValue)); + if (!replacementWrappers.length) return committedChanges; + + const owned = new Set(committedChanges); + const includeChangeTree = change => { + owned.add(change); + change.changes?.forEach(includeChangeTree); + }; + replacementWrappers.forEach(includeChangeTree); + return recorded.filter(change => owned.has(change)); + } + + projectionalLegacyChangeCount (context) { + return (context.legacyChanges || []).filter(change => + !change.operation || + !this.ignoreCommittedTextOperation(change.operation, context) + ).length; + } + + componentNodeIdForMorph (document, morph, bridgeCommand, context = {}) { + if (!morph) return null; + if (morph === this.trackedComponent) return document.root.id; + const path = []; + let current = morph; + while (current && current !== this.trackedComponent) { + path.unshift(current.name); + if (current === morph && bridgeCommand.kind === ComponentBridgeCommandKind.REMOVE_NODE) { + current = context.resolveMorph?.(bridgeCommand.parentId); + } else if (current === morph && bridgeCommand.kind === ComponentBridgeCommandKind.MOVE_NODE) { + current = context.resolveMorph?.(bridgeCommand.previousParentId); + } else { + current = current.owner; + } + } + if (current !== this.trackedComponent) return null; + if (bridgeCommand.kind === ComponentBridgeCommandKind.RENAME_NODE) { + path[path.length - 1] = bridgeCommand.previousName; + } + let node = document.root; + for (const name of path) { + node = node.children.find(child => child.name === name); + if (!node) return null; + } + return node.id; + } + + projectionalRuntimeParentDocument (commands, context) { + const parentPolicy = this.componentDescriptor?.stylePolicy?.parent; + if (!parentPolicy) return null; + const componentId = this.committedChangeAdapter.componentId; + const childrenOf = owner => (owner?.submorphs || owner?.children || []).slice(); + const childrenBeforeCommand = owner => { + const children = childrenOf(owner); + for (const command of commands) { + if (command.kind !== ComponentBridgeCommandKind.REMOVE_NODE || + command.parentId !== owner?.id) continue; + const removed = context.resolveMorph?.(command.nodeId); + if (removed && !children.includes(removed)) children.splice(command.index, 0, removed); + } + return children; + }; + const runtimeNameBeforeCommands = morph => commands.find(command => + command.kind === ComponentBridgeCommandKind.RENAME_NODE && + command.nodeId === morph?.id + )?.previousName || morph?.name; + const nodeFromSpec = (spec, path, isRoot = false) => { + const properties = spec?.COMMAND === 'add' ? spec.props : spec?.props || spec; + const name = properties?.name || (isRoot ? 'parent' : null); + if (typeof name !== 'string' || !name) return null; + const children = (properties.submorphs || []).map((child, index) => + nodeFromSpec(child, [...path, `${index}:${child?.props?.name || child?.name || 'unnamed'}`]) + ); + if (children.some(child => !child)) return null; + return new ComponentNode({ + id: isRoot + ? `${componentId}:parent-root` + : `${componentId}:parent:${path.map(encodeURIComponent).join('/')}`, + name, + provenance: localNodeProvenance(), + children + }); + }; + if (typeof parentPolicy.asBuildSpec === 'function') { + try { + const root = nodeFromSpec(parentPolicy.asBuildSpec(true), [], true); + if (root) { + return new ComponentDocument({ + componentId: `${componentId}:parent-policy`, + moduleId: this.componentModuleId, + exportName: `${this.componentDescriptor.componentName}ParentPolicy`, + root + }); + } + } catch { + return null; + } + } + const nodeFromRuntime = (morph, path, isRoot = false) => new ComponentNode({ + id: isRoot + ? `${componentId}:parent-root` + : `${componentId}:inherited:${path.map(encodeURIComponent).join('/')}`, + name: runtimeNameBeforeCommands(morph) || + (isRoot ? this.componentDescriptor.componentName : null), + provenance: localNodeProvenance(), + children: childrenBeforeCommand(morph).map(child => { + const childName = runtimeNameBeforeCommands(child); + return nodeFromRuntime(child, [...path, childName]); + }) + }); + return new ComponentDocument({ + componentId: `${componentId}:parent-snapshot`, + moduleId: this.componentModuleId, + exportName: `${this.componentDescriptor.componentName}ParentSnapshot`, + root: nodeFromRuntime(this.trackedComponent, [], true) + }); + } + + projectionalComponentDocumentResolver (componentModule = this.componentModule) { + const cache = new Map(); + const recorder = componentModule?.recorder || + componentModule?.env?.()?.recorder || {}; + return ({ expression }) => { + if (cache.has(expression)) return cache.get(expression); + if (!/^[A-Za-z_$][\w$]*$/.test(expression)) return null; + const reference = recorder[expression]; + const policy = reference?.isComponentDescriptor + ? reference.stylePolicy + : reference?.isPolicy ? reference : null; + if (!policy || typeof policy.asBuildSpec !== 'function') return null; + const metaSymbol = Symbol.for('lively-module-meta'); + const meta = reference?.[metaSymbol] || policy[metaSymbol] || {}; + const resolvedModuleId = meta.moduleId || componentModule?.id || this.componentModuleId; + const resolvedExportName = meta.exportedName || expression; + const resolvedComponentId = `${resolvedModuleId}#${resolvedExportName}:resolved`; + try { + const nestedPartReference = spec => { + const master = spec?.master?.isComponentDescriptor + ? spec.master.stylePolicy + : spec?.master; + const policies = [ + master, + master?._autoMaster, + master?.parent, + master?._parent + ]; + const componentMeta = policies + .map(policy => policy?.[metaSymbol]) + .find(candidate => + typeof candidate?.exportedName === 'string' && + candidate.exportedName && + Array.isArray(candidate.path) && candidate.path.length === 0); + return componentMeta + ? sourceComponentReference(componentMeta.exportedName) + : null; + }; + const nodeFromSpec = (spec, path = [], isRoot = false) => { + const name = spec?.name || (isRoot ? resolvedExportName : null); + if (typeof name !== 'string' || !name) { + throw new Error(`Resolved component ${expression} contains an unnamed node`); + } + const id = isRoot + ? `${resolvedComponentId}:root` + : `${resolvedComponentId}:node:${path.map(segment => + encodeURIComponent(segment)).join('/')}`; + return new ComponentNode({ + id, + name, + provenance: localNodeProvenance(), + partComponent: isRoot ? null : nestedPartReference(spec), + children: (spec?.submorphs || []).map((child, index) => + nodeFromSpec(child, [...path, `${index}:${child?.name || 'unnamed'}`])) + }); + }; + const document = new ComponentDocument({ + componentId: resolvedComponentId, + moduleId: resolvedModuleId, + exportName: resolvedExportName, + root: nodeFromSpec(policy.asBuildSpec(true), [], true) + }); + cache.set(expression, document); + return document; + } catch (error) { + cache.set(expression, null); + return null; + } + }; + } + + projectionalDerivedDescriptors (descriptor) { + const policy = descriptor?.stylePolicy || descriptor; + const dependants = policy?._dependants; + if (!dependants) return []; + const descriptors = []; + for (const expression of dependants) { + const policyOrDescriptor = derivedExpressionSerializer.deserializeExpr(expression); + const meta = policyOrDescriptor?.[Symbol.for('lively-module-meta')] || {}; + let derivedDescriptor = policyOrDescriptor; + if (meta.path?.length > 0) { + derivedDescriptor = module( + this.componentDescriptor.System || System, + meta.moduleId + ).recorder?.[meta.exportedName]; + } + if (derivedDescriptor?.isPolicy && meta.moduleId && meta.exportedName) { + derivedDescriptor = module( + this.componentDescriptor.System || System, + meta.moduleId + ).recorder?.[meta.exportedName] || derivedDescriptor; + } + if (!derivedDescriptor) { + throw new Error('Could not resolve a registered derived component'); + } + descriptors.push(derivedDescriptor); + } + return descriptors; + } + + projectionalModuleForId (moduleId) { + if (this.componentModule?.id === moduleId || this.componentModuleId === moduleId) { + return this.componentModule; + } + return module(this.componentDescriptor.System || System, moduleId); + } + + projectionalDerivedComponentDescription (descriptor, baseSourceAfter) { + const meta = descriptor?.[Symbol.for('lively-module-meta')] || + descriptor?.stylePolicy?.[Symbol.for('lively-module-meta')] || {}; + const moduleId = meta.moduleId || descriptor?.moduleName; + const exportName = meta.exportedName || descriptor?.componentName; + const derivedModule = this.projectionalModuleForId(moduleId); + return { + source: derivedModule.id === this.componentModule.id + ? baseSourceAfter + : derivedModule._source, + moduleId: derivedModule.id, + exportName, + componentId: `${derivedModule.id}#${exportName}:derived-projection`, + resolveComponentDocument: this.projectionalComponentDocumentResolver(derivedModule) + }; + } + + prepareProjectionalDerivedRename (batch) { + const command = batch.commands[0]; + if (batch.commands.length !== 1 || + command?.kind !== ComponentBridgeCommandKind.RENAME_NODE || + !batch.shadowProjection?.supported || + !this.componentDescriptor?.stylePolicy?._dependants?.size) return null; + try { + const baseSourceAfter = batch.shadowProjection.sourceAfter; + const propagation = planDerivedComponentRenamePropagation({ + root: this.componentDescriptor, + beforeParentDocument: batch.shadowProjection.beforeDocument, + afterParentDocument: batch.shadowProjection.document, + nodeId: batch.shadowProjection.steps[0].componentCommand.nodeId, + getDependants: descriptor => this.projectionalDerivedDescriptors(descriptor), + describeComponent: descriptor => + this.projectionalDerivedComponentDescription(descriptor, baseSourceAfter) + }); + if (!propagation.supported) return propagation; + const runtimeRenames = []; + for (const component of propagation.components) { + const activeComponent = component.dependant?._cachedComponent; + if (!activeComponent) continue; + const path = componentNodeNamePath(component.projection.beforeDocument, + batch.shadowProjection.steps[0].componentCommand.nodeId); + if (!path) continue; + let target = activeComponent; + for (const name of path) { + target = (target.submorphs || []).find(morph => morph.name === name); + if (!target) break; + } + if (!target) continue; + const beforeNode = findComponentNode( + component.projection.beforeDocument, + batch.shadowProjection.steps[0].componentCommand.nodeId + ); + const afterNode = findComponentNode( + component.projection.document, + batch.shadowProjection.steps[0].componentCommand.nodeId + ); + if (target.name !== beforeNode.name) { + throw new Error( + `Cached derived component ${component.exportName} changed while rename propagation was planned` + ); + } + runtimeRenames.push(Object.freeze({ + id: `${component.moduleId}#${component.exportName}:${beforeNode.id}`, + beforeName: beforeNode.name, + afterName: afterNode.name, + target + })); + } + return Object.freeze({ + ...propagation, + runtimeRenames: Object.freeze(runtimeRenames) + }); + } catch (error) { + return Object.freeze({ + supported: false, + components: Object.freeze([]), + modules: Object.freeze([]), + diagnostics: Object.freeze([Object.freeze({ + kind: ProjectionalRenameDiagnosticKind.DERIVED_DEPENDANTS, + message: error.message, + error + })]) + }); + } + } + + prepareProjectionalDerivedStructure (batch, context) { + const command = batch.commands[0]; + if (batch.commands.length !== 1 || + ![ + ComponentBridgeCommandKind.INTRODUCE_NODE, + ComponentBridgeCommandKind.REMOVE_NODE, + ComponentBridgeCommandKind.MOVE_NODE + ].includes(command?.kind) || + !batch.shadowProjection?.supported || + batch.shadowProjection.beforeDocument?.parentComponent || + !this.componentDescriptor?.stylePolicy?._dependants?.size) return null; + try { + const propagation = planDerivedComponentStructurePropagation({ + root: this.componentDescriptor, + beforeParentDocument: batch.shadowProjection.beforeDocument, + afterParentDocument: batch.shadowProjection.document, + getDependants: descriptor => this.projectionalDerivedDescriptors(descriptor), + describeComponent: descriptor => this.projectionalDerivedComponentDescription( + descriptor, + batch.shadowProjection.sourceAfter + ) + }); + if (!propagation.supported) return propagation; + const cachedComponents = propagation.components.filter( + ({ dependant }) => dependant?._cachedComponent + ); + let runtimeStructuralProjection = null; + if (cachedComponents.length) { + const nodeId = batch.shadowProjection.steps[0].componentCommand.nodeId; + const projection = projectCachedDerivedRuntimeStructure({ + components: cachedComponents, + nodeId, + commandKind: command.kind === ComponentBridgeCommandKind.REMOVE_NODE + ? DerivedRuntimeStructureProjectionKind.REMOVE + : command.kind === ComponentBridgeCommandKind.MOVE_NODE + ? DerivedRuntimeStructureProjectionKind.MOVE + : DerivedRuntimeStructureProjectionKind.INTRODUCE, + changeSetId: batch.changeSetId, + sourceMorph: command.kind === ComponentBridgeCommandKind.INTRODUCE_NODE + ? context.resolveMorph?.(command.nodeId) + : null + }); + if (projection) { + const resolveMorph = id => projection.resolveMorph(id) || context.resolveMorph?.(id); + runtimeStructuralProjection = Object.freeze({ + changeSet: projection.changeSet, + inverseChangeSet: projection.inverseChangeSet, + runtimeContext: this.runtimeProjectionContext({ ...context, resolveMorph }) + }); + } + } + return Object.freeze({ + ...propagation, + runtimeRenames: Object.freeze([]), + runtimeStructuralProjection + }); + } catch (error) { + return Object.freeze({ + supported: false, + components: Object.freeze([]), + modules: Object.freeze([]), + diagnostics: Object.freeze([Object.freeze({ + kind: ProjectionalStructuralDiagnosticKind.DERIVED_DEPENDANTS, + message: error.message, + error + })]) + }); + } + } + + prepareShadowProjection (commands, context) { + if (!this.componentModule || !this.componentDescriptor) return null; + const projectionSequence = this._shadowProjectionCounter || 0; + this._shadowProjectionCounter = projectionSequence + 1; + return prepareShadowScalarProjection({ + source: this.currentModuleSource, + moduleId: this.componentModuleId, + exportName: this.componentDescriptor.componentName, + componentId: this.committedChangeAdapter.componentId, + bridgeCommands: commands, + parentDocument: this.projectionalRuntimeParentDocument(commands, context), + resolveComponentDocument: this.projectionalComponentDocumentResolver(), + beforeDocument: this._projectionalSource === this.currentModuleSource + ? this._projectionalDocument + : null, + projectionId: `shadow-${this.committedChangeAdapter.componentId}-${projectionSequence}`, + resolveNodeId: (document, bridgeCommand) => this.componentNodeIdForMorph( + document, + context.resolveMorph?.( + bridgeCommand.kind === ComponentBridgeCommandKind.INTRODUCE_NODE + ? bridgeCommand.parentId + : bridgeCommand.nodeId + ), + bridgeCommand, + context + ), + resolveDestinationParentId: (document, bridgeCommand) => + this.componentNodeIdForMorph( + document, + context.resolveMorph?.(bridgeCommand.parentId), + { kind: ComponentBridgeCommandKind.SET_PROPERTY }, + context + ), + runtimeNodeNameFor: bridgeCommand => + context.resolveMorph?.(bridgeCommand.nodeId)?.name, + runtimeOrderingNameFor: bridgeCommand => { + const morph = context.resolveMorph?.(bridgeCommand.nodeId); + const parent = context.resolveMorph?.(bridgeCommand.parentId); + const children = parent?.submorphs || parent?.children; + if (!morph || !Array.isArray(children)) return undefined; + const index = children.indexOf(morph); + return index < 0 ? undefined : children[index + 1]?.name ?? null; + }, + valueExpressionFor: bridgeCommand => bridgeCommand.kind === ComponentBridgeCommandKind.EDIT_TEXT + ? getTextAttributesExpr(context.resolveMorph?.(bridgeCommand.nodeId)) + : getValueExpr( + bridgeCommand.kind === ComponentBridgeCommandKind.SET_MASTER + ? 'master' + : bridgeCommand.property, + bridgeCommand.value + ), + introducedNodeFor: ({ + document, + parentId, + index, + bridgeCommand, + partComponent, + materializePartSubtree + }) => { + const target = context.resolveMorph?.(bridgeCommand.nodeId); + return serializeRuntimeComponentNode({ + document, + parentId, + index, + morph: target, + partComponent, + materializePartSubtree, + allocateName: candidate => { + while (this.trackedComponent.withAllSubmorphsDetect?.(morph => + morph !== target && morph.name === candidate)) { + candidate = string.incName(candidate); + } + return this.componentDescriptor.ensureNoNameCollisionInDerived?.(candidate, true) || + candidate; + } + }); + }, + runtimeLayoutFor: spec => this.projectionalRuntimeLayoutFor(spec, context) + }); + } + + projectionalRuntimeLayoutFor ({ + beforeDocument, + semanticDelta, + bridgeCommand + }, context) { + const ownerId = bridgeCommand.kind === ComponentBridgeCommandKind.RENAME_NODE + ? findComponentParent(beforeDocument, semanticDelta.nodeId)?.id + : bridgeCommand.kind === ComponentBridgeCommandKind.REMOVE_NODE + ? semanticDelta.parentId + : bridgeCommand.kind === ComponentBridgeCommandKind.MOVE_NODE && + semanticDelta.fromParentId !== semanticDelta.toParentId + ? semanticDelta.fromParentId + : null; + if (!ownerId) return null; + const layoutModel = findComponentLayoutModel(beforeDocument, ownerId); + const runtimeOwnerId = bridgeCommand.kind === ComponentBridgeCommandKind.MOVE_NODE + ? bridgeCommand.previousParentId + : bridgeCommand.kind === ComponentBridgeCommandKind.REMOVE_NODE + ? bridgeCommand.parentId + : context.resolveMorph?.(bridgeCommand.nodeId)?.owner?.id; + const runtimeOwner = context.resolveMorph?.(runtimeOwnerId); + if (!runtimeOwner?.layout || typeof runtimeOwner.layout.copy !== 'function') return null; + + if (bridgeCommand.kind === ComponentBridgeCommandKind.RENAME_NODE) { + const before = runtimeOwner.layout; + const after = before.copy(); + if (typeof after.handleRenamingOf !== 'function') return null; + after.handleRenamingOf(semanticDelta.before, semanticDelta.after); + return Object.freeze({ + ownerId: runtimeOwner.id, + before, + after, + applyWhenAdopting: true + }); + } + + if (!layoutModel) return null; + + const policy = this.componentDescriptor?.stylePolicy; + if (!policy) return null; + const ownerPath = componentNodeNamePath(beforeDocument, ownerId); + if (!ownerPath) return null; + const sourceLayout = effectivePolicyLayoutAtPath(policy, ownerPath); + if (!sourceLayout || typeof sourceLayout.copy !== 'function') return null; + return Object.freeze({ + ownerId: runtimeOwner.id, + before: sourceLayout.copy(), + after: runtimeOwner.layout, + applyWhenAdopting: false + }); + } + + runtimeProjectionContext (context) { + const childrenOf = owner => owner?.submorphs || owner?.children || []; + const attachmentOf = morph => morph?.owner + ? Object.freeze({ + kind: MorphicAttachmentKind.ATTACHED, + ownerId: morph.owner.id, + index: childrenOf(morph.owner).indexOf(morph) + }) + : Object.freeze({ kind: MorphicAttachmentKind.DETACHED }); + return { + resolveMorph: context.resolveMorph, + readMorphProperty: (morph, property) => property in morph + ? morph[property] + : typeof morph.getProperty === 'function' + ? morph.getProperty(property) + : morph._morphicState?.[property], + valuesEqual: (current, expected, operation) => { + if (Object.is(current, expected)) return true; + if (typeof current?.equals === 'function' && current.equals(expected)) { + return true; + } + if (operation?.valueSemantics !== MorphicValueSemantics.SNAPSHOT) return false; + const currentSnapshot = operation.snapshotValue(current); + return operation.snapshotValuesEqual(currentSnapshot, expected); + }, + setMorphProperty: (morph, property, value) => { + const apply = () => { + if (property in morph) morph[property] = value; + else if (typeof morph.setProperty === 'function') morph.setProperty(property, value); + else morph[property] = value; + }; + return typeof morph.withMetaDo === 'function' + ? morph.withMetaDo({ + reconcileChanges: false, + origin: 'runtime-projection', + undoable: false, + doNotOverride: true + }, apply) + : apply(); + }, + validateMoveMorph: (morph, from) => { + const current = attachmentOf(morph); + if (current.kind !== from.kind || + current.ownerId !== from.ownerId || current.index !== from.index) { + throw new Error(`Precondition failed for structural morph ${morph.id}`); + } + }, + moveMorph: (morph, from, to) => { + const destination = to.kind === MorphicAttachmentKind.ATTACHED + ? context.resolveMorph?.(to.ownerId) + : null; + const apply = () => { + if (from.kind === MorphicAttachmentKind.ATTACHED) { + if (typeof morph.remove === 'function') morph.remove(); + else { + const owner = context.resolveMorph?.(from.ownerId); + const children = childrenOf(owner); + children.splice(children.indexOf(morph), 1); + morph.owner = null; + } + } + if (to.kind === MorphicAttachmentKind.ATTACHED) { + if (typeof destination?.addMorphAt === 'function') { + destination.addMorphAt(morph, to.index); + } else { + childrenOf(destination).splice(to.index, 0, morph); + morph.owner = destination; + } + } + }; + const metadataTarget = morph.owner || destination || morph; + return typeof metadataTarget.withMetaDo === 'function' + ? metadataTarget.withMetaDo({ + reconcileChanges: false, + origin: 'runtime-projection', + undoable: false + }, apply) + : apply(); + } + }; + } + + projectionalTransactionAdapters (transaction, runtimeContext) { + return { + sourceStore: { + read: () => this.currentModuleSource, + write: source => { + this.componentModule.setSource(source); + this._projectionalSource = source; + } + }, + documentStore: { + read: () => this._projectionalDocument || transaction.beforeDocument, + write: nextDocument => { this._projectionalDocument = nextDocument; } + }, + runtimeContext + }; + } + + projectionalDocumentForCurrentSource () { + if (this._projectionalSource === this.currentModuleSource && + this._projectionalDocument) { + return Object.freeze({ + supported: true, + document: this._projectionalDocument, + diagnostics: Object.freeze([]) + }); + } + return parseComponentSource({ + source: this.currentModuleSource, + moduleId: this.componentModuleId, + exportName: this.componentDescriptor.componentName, + componentId: this.committedChangeAdapter.componentId + }); + } + + resolveProjectionalCommandTarget (target) { + if (!this.canRecordProjectionalEdit()) { + return unsupportedProjectionalCommand( + ProjectionalCommandDiagnosticKind.UNDO_UNAVAILABLE, + 'The component command cannot safely join the current undo transaction' + ); + } + + const parsed = this.projectionalDocumentForCurrentSource(); + if (!parsed.supported) { + return unsupportedProjectionalCommand( + ProjectionalCommandDiagnosticKind.SOURCE_UNSUPPORTED, + 'The current component source cannot be represented projectionally', + { sourceDiagnostics: parsed.diagnostics } + ); + } + const document = parsed.document; + const nodeId = this.componentNodeIdForMorph(document, target, { + kind: ComponentBridgeCommandKind.SET_PROPERTY + }); + if (!nodeId || typeof target.id !== 'string' || !target.id) { + return unsupportedProjectionalCommand( + ProjectionalCommandDiagnosticKind.TARGET_UNRESOLVED, + 'The runtime morph cannot be resolved in the component document' + ); + } + return Object.freeze({ document, nodeId }); + } + + commitProjectionalScalarCommand ({ + document, + command, + target, + nodeId, + runtimeValueAfter, + label + }) { + const runtimeContext = this.runtimeProjectionContext({ + resolveMorph: runtimeId => runtimeId === target.id ? target : null + }); + const runtimeValueBefore = runtimeContext.readMorphProperty( + target, + command.property + ); + const commandSequence = this._projectionalCommandCounter || 0; + this._projectionalCommandCounter = commandSequence + 1; + const planned = prepareScalarComponentTransaction({ + id: `component-command-${document.componentId}-${commandSequence}`, + source: this.currentModuleSource, + document, + command, + resolveRuntimeTargetId: semanticNodeId => semanticNodeId === nodeId + ? target.id + : null, + resolveRuntimeValue: ({ phase }) => Object.freeze({ + available: true, + value: phase === 'before' ? runtimeValueBefore : runtimeValueAfter + }) + }); + if (!planned.supported) { + return unsupportedProjectionalCommand( + ProjectionalCommandDiagnosticKind.PLANNING_FAILED, + `The ${command.kind} command could not be planned atomically`, + { planningDiagnostics: planned.diagnostics } + ); + } + + const transaction = planned.transaction; + const adapters = this.projectionalTransactionAdapters(transaction, runtimeContext); + const committed = commitPreparedComponentTransaction(transaction, adapters); + const editTransaction = new ProjectionalComponentEditTransaction( + transaction, + adapters, + { + label, + afterReplay: () => this.refreshAndTrackProjectionalDependants() + } + ); + this.recordProjectionalEditTransaction(editTransaction); + this.refreshAndTrackProjectionalDependants(); + return Object.freeze({ + committed: true, + diagnostics: Object.freeze([]), + ...committed, + editTransaction + }); + } + + /** + * Removes a local property override as an explicit component command. + * The caller supplies the already-resolved inherited/default runtime value; + * no runtime mutation should be performed before this method succeeds. + * A non-committed result leaves source, document, runtime, and history intact. + */ + clearPropertyOverride (options = {}) { + const { target, property, effectiveValue } = options; + if (!target || typeof property !== 'string' || !property || + !Object.prototype.hasOwnProperty.call(options, 'effectiveValue')) { + throw new Error('Clearing a component property override requires a target, property, and effective value'); + } + const resolution = this.resolveProjectionalCommandTarget(target); + if (resolution.committed === false) return resolution; + const { document, nodeId } = resolution; + + const command = ClearPropertyOverride({ + componentId: document.componentId, + expectedRevision: document.revision, + nodeId, + property + }); + return this.commitProjectionalScalarCommand({ + document, + command, + target, + nodeId, + runtimeValueAfter: effectiveValue, + label: `clear component ${property} override` + }); + } + + /** + * Sets a property through an explicit or serializer-backed opaque component + * command before mutating runtime. Unrepresentable values return a + * non-committed result without mutating runtime. + */ + setProperty (options = {}) { + const { target, property, value } = options; + if (!target || typeof property !== 'string' || !property || + !Object.prototype.hasOwnProperty.call(options, 'value')) { + throw new Error('Setting a component property requires a target, property, and value'); + } + const resolution = this.resolveProjectionalCommandTarget(target); + if (resolution.committed === false) return resolution; + const { document, nodeId } = resolution; + const commandSpec = { + componentId: document.componentId, + expectedRevision: document.revision, + nodeId, + property + }; + let command; + try { + command = isProjectionalExplicitValue(value) + ? SetProperty({ ...commandSpec, value }) + : (() => { + const expression = getValueExpr(property, value); + return SetOpaqueProperty({ + ...commandSpec, + expression: expression?.__expr__, + requiredBindings: componentImportBindingsFromExpression(expression?.bindings || {}) + }); + })(); + } catch (error) { + return unsupportedProjectionalCommand( + ProjectionalCommandDiagnosticKind.PLANNING_FAILED, + `The ${property} value could not be serialized for a component command`, + { cause: error } + ); + } + return this.commitProjectionalScalarCommand({ + document, + command, + target, + nodeId, + runtimeValueAfter: value, + label: `set component ${property}` + }); + } + + commitProjectionalBatch (batch, context) { + const ownedLegacyChanges = this.projectionallyOwnedLegacyChanges(batch, context); + const isMultiScalarBatch = batch.commands.length > 1 && + batch.commands.every(command => [ + ComponentBridgeCommandKind.SET_PROPERTY, + ComponentBridgeCommandKind.SET_MASTER, + ComponentBridgeCommandKind.EDIT_TEXT, + ComponentBridgeCommandKind.RENAME_NODE + ].includes(command.kind)) && + !(this.componentDescriptor?.stylePolicy?._dependants?.size > 0) && + !batch.derivedPropagation && + !batch.policyCacheProjection; + if (batch.diagnostics.length || + (!isMultiScalarBatch && (batch.commands.length !== 1 || ![ + ComponentBridgeCommandKind.SET_PROPERTY, + ComponentBridgeCommandKind.SET_MASTER, + ComponentBridgeCommandKind.EDIT_TEXT, + ComponentBridgeCommandKind.RENAME_NODE, + ComponentBridgeCommandKind.INTRODUCE_NODE, + ComponentBridgeCommandKind.REMOVE_NODE, + ComponentBridgeCommandKind.MOVE_NODE + ].includes(batch.commands[0].kind))) || + this.projectionalLegacyChangeCount(context) !== batch.commands.length || + !this.canRecordProjectionalEdit(ownedLegacyChanges) || + !batch.shadowProjection?.supported) return null; + + const transaction = preparedComponentTransactionFromShadowProjection({ + id: `${batch.changeSetId}:component`, + shadowProjection: batch.shadowProjection + }); + const adapters = this.projectionalTransactionAdapters( + transaction, + this.runtimeProjectionContext(context) + ); + const textRefresh = batch.commands[0].kind === ComponentBridgeCommandKind.EDIT_TEXT + ? Object.freeze({ + document: batch.shadowProjection.beforeDocument, + nodeId: batch.shadowProjection.steps[0].componentCommand.nodeId + }) + : null; + const policyCachePlanCount = batch.policyCacheProjection?.renames?.length || + batch.policyCacheProjection?.changes?.length || 0; + const refreshesDependants = + batch.commands[0].kind !== ComponentBridgeCommandKind.RENAME_NODE; + const moveStep = batch.commands[0].kind === ComponentBridgeCommandKind.MOVE_NODE + ? batch.shadowProjection.steps[0] + : null; + const refreshMovedTarget = moveStep + ? ({ direction = ComponentTransactionDirection.FORWARD } = {}) => + this.refreshProjectionalMovedTarget( + context.resolveMorph?.(batch.commands[0].nodeId), + { + direction, + materializedNode: + moveStep.componentCommand.inheritanceTransition?.node || null, + materializedBindings: + moveStep.componentCommand.inheritanceTransition?.requiredBindings || [] + } + ) + : null; + const refreshOptions = refreshesDependants + ? { + afterReplay: replay => { + refreshMovedTarget?.(replay); + return this.refreshAndTrackProjectionalDependants(textRefresh); + } + } + : {}; + const componentEdit = new ProjectionalComponentEditTransaction( + transaction, + adapters, + policyCachePlanCount + ? {} + : refreshOptions + ); + let policyCacheTransaction = null; + let policyCacheStores = null; + if (policyCachePlanCount) { + policyCacheTransaction = batch.policyCacheProjection.kind === + ProjectionalPolicyCacheProjectionKind.RENAME + ? new PreparedPolicyCacheRenameTransaction({ + id: `${batch.changeSetId}:policy-cache`, + renames: batch.policyCacheProjection.renames + }) + : new PreparedPolicyCachePropertyTransaction({ + id: `${batch.changeSetId}:policy-cache`, + changes: batch.policyCacheProjection.changes + }); + policyCacheStores = batch.policyCacheProjection.stores; + if (policyCacheTransaction instanceof PreparedPolicyCacheRenameTransaction) { + validatePreparedPolicyCacheRenames( + policyCacheTransaction, + policyCacheStores, + PolicyCacheTransactionDirection.FORWARD + ); + } else { + validatePreparedPolicyCacheProperties( + policyCacheTransaction, + policyCacheStores, + PolicyCacheTransactionDirection.FORWARD + ); + } + } + const applyPolicyCache = direction => policyCacheTransaction instanceof + PreparedPolicyCacheRenameTransaction + ? applyPreparedPolicyCacheRenames(policyCacheTransaction, { + stores: policyCacheStores, + direction + }) + : applyPreparedPolicyCacheProperties(policyCacheTransaction, { + stores: policyCacheStores, + direction + }); + let derivedTransaction = null; + let derivedStores = null; + let derivedRuntimeTransaction = null; + let derivedRuntimeStores = null; + let derivedRuntimeChangeTransaction = null; + let derivedRuntimeChangeContext = null; + if (batch.derivedPropagation?.supported) { + derivedTransaction = new PreparedDerivedPropagationTransaction({ + id: `${batch.changeSetId}:derived`, + modules: batch.derivedPropagation.modules + }); + derivedStores = new Map(derivedTransaction.modules.map(plan => { + const derivedModule = this.projectionalModuleForId(plan.moduleId); + const isBaseModule = derivedModule.id === this.componentModule.id; + return [plan.moduleId, { + read: () => { + const source = derivedModule._source; + // Before the base transaction commits, expose its already-validated + // intermediate source to the derived preflight. Composite replay + // always installs that intermediate source before this store writes. + return isBaseModule && source === transaction.sourceBefore + ? transaction.sourceAfter + : source; + }, + write: source => derivedModule.setSource(source) + }]; + })); + validatePreparedDerivedPropagation( + derivedTransaction, + derivedStores, + DerivedTransactionDirection.FORWARD + ); + derivedRuntimeTransaction = new PreparedDerivedRuntimeRenameTransaction({ + id: `${batch.changeSetId}:derived-runtime`, + renames: batch.derivedPropagation.runtimeRenames || [] + }); + derivedRuntimeStores = new Map(derivedRuntimeTransaction.renames.map(rename => { + const target = batch.derivedPropagation.runtimeRenames + .find(candidate => candidate.id === rename.id).target; + return [rename.id, { + read: () => target.name, + write: name => { + const apply = () => { target.name = name; }; + return typeof target.withMetaDo === 'function' + ? target.withMetaDo({ + reconcileChanges: false, + origin: 'runtime-projection', + undoable: false + }, apply) + : apply(); + } + }]; + })); + validatePreparedDerivedRuntimeRenames( + derivedRuntimeTransaction, + derivedRuntimeStores, + DerivedTransactionDirection.FORWARD + ); + const structuralProjection = batch.derivedPropagation.runtimeStructuralProjection; + if (structuralProjection) { + derivedRuntimeChangeTransaction = new PreparedDerivedRuntimeChangeTransaction({ + id: `${batch.changeSetId}:derived-runtime-change`, + changeSet: structuralProjection.changeSet, + inverseChangeSet: structuralProjection.inverseChangeSet + }); + derivedRuntimeChangeContext = structuralProjection.runtimeContext; + validatePreparedDerivedRuntimeChanges( + derivedRuntimeChangeTransaction, + derivedRuntimeChangeContext, + DerivedTransactionDirection.FORWARD + ); + } + } + const committed = commitPreparedComponentTransaction(transaction, { + ...adapters, + runtimeCommitMode: ComponentRuntimeCommitMode.ADOPT_ALREADY_APPLIED + }); + let derivedSourcesCommitted = false; + let derivedRuntimeCommitted = false; + let derivedRuntimeChangeCommitted = false; + let policyCacheCommitted = false; + try { + if (derivedTransaction?.modules.length) { + applyPreparedDerivedPropagation(derivedTransaction, { stores: derivedStores }); + derivedSourcesCommitted = true; + } + if (derivedRuntimeTransaction?.renames.length) { + applyPreparedDerivedRuntimeRenames(derivedRuntimeTransaction, { + stores: derivedRuntimeStores + }); + derivedRuntimeCommitted = true; + } + if (derivedRuntimeChangeTransaction) { + applyPreparedDerivedRuntimeChanges(derivedRuntimeChangeTransaction, { + runtimeContext: derivedRuntimeChangeContext + }); + derivedRuntimeChangeCommitted = true; + } + if (policyCacheTransaction) { + applyPolicyCache(PolicyCacheTransactionDirection.FORWARD); + policyCacheCommitted = true; + } + } catch (error) { + const rollbackErrors = error.rollbackErrors?.slice() || []; + if (policyCacheCommitted) { + try { + applyPolicyCache(PolicyCacheTransactionDirection.REVERSE); + } catch (rollbackError) { + rollbackErrors.push(rollbackError); + } + } + if (derivedRuntimeChangeCommitted) { + try { + applyPreparedDerivedRuntimeChanges(derivedRuntimeChangeTransaction, { + runtimeContext: derivedRuntimeChangeContext, + direction: DerivedTransactionDirection.REVERSE + }); + } catch (rollbackError) { + rollbackErrors.push(rollbackError); + } + } + if (derivedRuntimeCommitted) { + try { + applyPreparedDerivedRuntimeRenames(derivedRuntimeTransaction, { + stores: derivedRuntimeStores, + direction: DerivedTransactionDirection.REVERSE + }); + } catch (rollbackError) { + rollbackErrors.push(rollbackError); + } + } + if (derivedSourcesCommitted) { + try { + applyPreparedDerivedPropagation(derivedTransaction, { + stores: derivedStores, + direction: DerivedTransactionDirection.REVERSE + }); + } catch (rollbackError) { + rollbackErrors.push(rollbackError); + } + } + try { + applyPreparedComponentTransaction(transaction, { + ...adapters, + runtimeCommitMode: ComponentRuntimeCommitMode.APPLY, + direction: ComponentTransactionDirection.REVERSE + }); + } catch (rollbackError) { + rollbackErrors.push(rollbackError); + } + if (rollbackErrors.length) error.rollbackErrors = rollbackErrors; + throw error; + } + const derivedEdit = derivedTransaction?.modules.length + ? new ProjectionalDerivedEditTransaction(derivedTransaction, derivedStores) + : null; + const derivedRuntimeEdit = derivedRuntimeTransaction?.renames.length + ? new ProjectionalDerivedRuntimeEditTransaction( + derivedRuntimeTransaction, + derivedRuntimeStores + ) + : null; + const derivedRuntimeChangeEdit = derivedRuntimeChangeTransaction + ? new ProjectionalDerivedRuntimeChangeEditTransaction( + derivedRuntimeChangeTransaction, + derivedRuntimeChangeContext + ) + : null; + const policyCacheEdit = policyCacheTransaction + ? policyCacheTransaction instanceof PreparedPolicyCacheRenameTransaction + ? new ProjectionalPolicyCacheEditTransaction( + policyCacheTransaction, + policyCacheStores, + refreshOptions + ) + : new ProjectionalPolicyCachePropertyEditTransaction( + policyCacheTransaction, + policyCacheStores, + refreshOptions + ) + : null; + const derivedEdits = [ + derivedEdit, + derivedRuntimeEdit, + derivedRuntimeChangeEdit, + policyCacheEdit + ].filter(Boolean); + const editTransaction = derivedEdits.length + ? new CompositeEditTransaction([componentEdit, ...derivedEdits], { + label: 'component edit with derived propagation', + metadata: { componentTransactionId: transaction.id } + }) + : componentEdit; + this.recordProjectionalEditTransaction(editTransaction, ownedLegacyChanges); + this._projectionallyConsumedChanges ||= new WeakSet(); + ownedLegacyChanges.forEach(change => this._projectionallyConsumedChanges.add(change)); + if (refreshesDependants) { + refreshMovedTarget?.({ + direction: ComponentTransactionDirection.FORWARD + }); + this.refreshAndTrackProjectionalDependants(textRefresh); + } + return Object.freeze({ + ...committed, + derivedPropagation: batch.derivedPropagation || null, + derivedTransaction, + derivedRuntimeTransaction, + derivedRuntimeChangeTransaction, + policyCacheTransaction, + editTransaction + }); + } + + prepareProjectionalPolicyCacheProjection (batch, context) { + return this.prepareProjectionalPolicyCacheRename(batch) || + this.prepareProjectionalPolicyCacheProperty(batch, context) || + this.prepareProjectionalPolicyCacheStructure(batch, context); + } + + prepareProjectionalPolicyCacheStructure (batch, context) { + const command = batch.commands[0]; + if (batch.commands.length !== 1 || + ![ + ComponentBridgeCommandKind.INTRODUCE_NODE, + ComponentBridgeCommandKind.REMOVE_NODE, + ComponentBridgeCommandKind.MOVE_NODE + ].includes(command?.kind) || + !batch.shadowProjection?.supported) return null; + const policy = this.componentDescriptor?.stylePolicy; + if (!policy?.spec) return null; + + const step = batch.shadowProjection.steps[0]; + const beforeDocument = batch.shadowProjection.beforeDocument; + const { semanticDelta } = step.reduction; + const ownerRuntimeIds = new Map(); + if (command.kind === ComponentBridgeCommandKind.INTRODUCE_NODE) { + ownerRuntimeIds.set(semanticDelta.parentId, command.parentId); + } else if (command.kind === ComponentBridgeCommandKind.REMOVE_NODE) { + ownerRuntimeIds.set(semanticDelta.parentId, command.parentId); + } else { + ownerRuntimeIds.set(semanticDelta.fromParentId, command.previousParentId); + ownerRuntimeIds.set(semanticDelta.toParentId, command.parentId); + } + + const changes = []; + const stores = new Map(); + for (const [ownerId, runtimeOwnerId] of ownerRuntimeIds) { + if (!findComponentLayoutModel(beforeDocument, ownerId)) continue; + const path = componentNodeNamePath(beforeDocument, ownerId); + if (!path) continue; + let current = policy.spec; + for (const name of path) { + const properties = policySpecProperties(current); + current = (properties?.submorphs || []).find(spec => + policySpecProperties(spec)?.name === name + ); + if (!current) break; + } + const ownerProperties = current && policySpecProperties(current); + const beforeValue = ownerProperties?.layout; + const runtimeLayout = context.resolveMorph?.(runtimeOwnerId)?.layout; + if (!beforeValue || typeof runtimeLayout?.copy !== 'function') continue; + const afterValue = runtimeLayout.copy(); + const id = `${this.componentModuleId}#${this.componentDescriptor.componentName}:${ownerId}:layout:structure`; + changes.push(Object.freeze({ + id, + property: 'layout', + beforeValue, + afterValue + })); + stores.set(id, { + read: () => ownerProperties.layout, + write: layout => { ownerProperties.layout = layout; } + }); + } + return Object.freeze({ + kind: ProjectionalPolicyCacheProjectionKind.PROPERTY, + supported: true, + changes: Object.freeze(changes), + stores, + diagnostics: Object.freeze([]) + }); + } + + prepareProjectionalPolicyCacheProperty (batch, context) { + const command = batch.commands[0]; + if (batch.commands.length !== 1 || + ![ + ComponentBridgeCommandKind.SET_PROPERTY, + ComponentBridgeCommandKind.EDIT_TEXT, + ComponentBridgeCommandKind.SET_MASTER + ].includes(command?.kind) || + !batch.shadowProjection?.supported) return null; + const policy = this.componentDescriptor?.stylePolicy; + if (!policy?.spec) { + return Object.freeze({ + kind: ProjectionalPolicyCacheProjectionKind.PROPERTY, + supported: true, + changes: Object.freeze([]), + stores: new Map(), + diagnostics: Object.freeze([]) + }); + } + const componentCommand = batch.shadowProjection.steps[0].componentCommand; + const property = command.kind === ComponentBridgeCommandKind.EDIT_TEXT + ? 'textAndAttributes' + : command.kind === ComponentBridgeCommandKind.SET_MASTER + ? 'master' + : command.property; + const afterValue = command.kind === ComponentBridgeCommandKind.EDIT_TEXT + ? projectionalPolicyTextAndAttributes( + context.resolveMorph?.(command.nodeId)?.textAndAttributes || command.value + ) + : command.value; + const path = componentNodeNamePath( + batch.shadowProjection.beforeDocument, + componentCommand.nodeId + ); + if (!path) { + return Object.freeze({ + kind: ProjectionalPolicyCacheProjectionKind.PROPERTY, + supported: true, + changes: Object.freeze([]), + stores: new Map(), + diagnostics: Object.freeze([]) + }); + } + let current = policy.spec; + let missingIndex = -1; + for (let index = 0; index < path.length; index++) { + const properties = policySpecProperties(current); + const next = (properties?.submorphs || []).find(spec => + policySpecProperties(spec)?.name === path[index] + ); + if (!next) { + missingIndex = index; + break; + } + current = next; + } + if (missingIndex >= 0) { + const ownerProperties = policySpecProperties(current); + if (!ownerProperties) return null; + let addition = { name: path[path.length - 1], [property]: afterValue }; + for (let index = path.length - 2; index >= missingIndex; index--) { + addition = { name: path[index], submorphs: [addition] }; + } + const beforeValue = ownerProperties.submorphs; + const afterSubmorphs = [...(beforeValue || []), addition]; + const id = `${this.componentModuleId}#${this.componentDescriptor.componentName}:${componentCommand.nodeId}:${property}:recover`; + return Object.freeze({ + kind: ProjectionalPolicyCacheProjectionKind.PROPERTY, + supported: true, + changes: Object.freeze([Object.freeze({ + id, + property: 'submorphs', + beforeValue, + afterValue: afterSubmorphs + })]), + stores: new Map([[id, { + read: () => ownerProperties.submorphs, + write: value => { + if (value === undefined) delete ownerProperties.submorphs; + else ownerProperties.submorphs = value; + } + }]]), + diagnostics: Object.freeze([]) + }); + } + const subSpec = policySpecProperties(current); + if (!subSpec) return null; + const id = `${this.componentModuleId}#${this.componentDescriptor.componentName}:${componentCommand.nodeId}:${property}`; + return Object.freeze({ + kind: ProjectionalPolicyCacheProjectionKind.PROPERTY, + supported: true, + changes: Object.freeze([Object.freeze({ + id, + property, + beforeValue: Object.prototype.hasOwnProperty.call(subSpec, property) + ? subSpec[property] + : undefined, + afterValue + })]), + stores: new Map([[id, { + read: () => subSpec[property], + write: value => { + if (value === undefined) delete subSpec[property]; + else subSpec[property] = value; + } + }]]), + diagnostics: Object.freeze([]) + }); + } + + prepareProjectionalPolicyCacheRename (batch) { + const command = batch.commands[0]; + if (batch.commands.length !== 1 || + command?.kind !== ComponentBridgeCommandKind.RENAME_NODE || + !batch.shadowProjection?.supported) return null; + + const entries = [{ + id: `${this.componentModuleId}#${this.componentDescriptor.componentName}`, + descriptor: this.componentDescriptor, + document: batch.shadowProjection.beforeDocument, + required: true + }]; + for (const component of batch.derivedPropagation?.components || []) { + entries.push({ + id: `${component.moduleId}#${component.exportName}`, + descriptor: component.dependant, + document: component.projection.beforeDocument, + required: component.projection.sourceBefore !== component.projection.sourceAfter + }); + } + + const renames = []; + const recoveries = []; + const layoutChanges = []; + const stores = new Map(); + const planLayoutReferenceRename = (id, path, ownerProperties) => { + const cachedLayout = ownerProperties?.layout; + const cachedPolicies = cachedLayout?.getSpec?.().resizePolicies || + cachedLayout?.config?.resizePolicies; + if (!Array.isArray(cachedPolicies) || + !cachedPolicies.some(([name]) => name === command.previousName) || + typeof cachedLayout.copy !== 'function') return; + const afterLayout = cachedLayout.copy(); + if (typeof afterLayout.handleRenamingOf !== 'function') return; + afterLayout.handleRenamingOf(command.previousName, command.name); + const layoutId = `${id}:layout:${path.slice(0, -1).join('/')}`; + layoutChanges.push(Object.freeze({ + id: layoutId, + property: 'layout', + beforeValue: cachedLayout, + afterValue: afterLayout + })); + stores.set(layoutId, { + read: () => ownerProperties.layout, + write: layout => { ownerProperties.layout = layout; } + }); + }; + const semanticNodeId = batch.shadowProjection.steps[0].componentCommand.nodeId; + for (const { id, descriptor, document, required } of entries) { + const policy = descriptor?.stylePolicy; + // Some bridge integrations deliberately use a lightweight descriptor + // without a materialized policy cache. In that case there is no fourth + // transaction domain to synchronize. + if (!policy?.spec) continue; + const path = componentNodeNamePath(document, semanticNodeId); + if (!path) continue; + let current = policy.spec; + let parentProperties = null; + let missingIndex = -1; + for (let index = 0; index < path.length; index++) { + const properties = policySpecProperties(current); + const next = (properties?.submorphs || []).find(spec => + policySpecProperties(spec)?.name === path[index] + ); + if (!next) { + missingIndex = index; + break; + } + parentProperties = properties; + current = next; + } + if (missingIndex >= 0) { + // A derived component with no local source reference inherits the + // already-updated parent policy and has no local cache entry to mutate. + if (!required) continue; + const ownerProperties = policySpecProperties(current); + if (!ownerProperties) continue; + planLayoutReferenceRename(id, path, ownerProperties); + let addition = { name: command.name }; + for (let index = path.length - 2; index >= missingIndex; index--) { + addition = { name: path[index], submorphs: [addition] }; + } + const beforeValue = ownerProperties.submorphs; + const afterValue = [...(beforeValue || []), addition]; + const recoveryId = `${id}:recover:${path.slice(0, missingIndex).join('/')}`; + recoveries.push(Object.freeze({ + id: recoveryId, + property: 'submorphs', + beforeValue, + afterValue + })); + stores.set(recoveryId, { + read: () => ownerProperties.submorphs, + write: value => { + if (value === undefined) delete ownerProperties.submorphs; + else ownerProperties.submorphs = value; + } + }); + continue; + } + const subSpec = policySpecProperties(current); + if (!subSpec) continue; + planLayoutReferenceRename(id, path, parentProperties); + if (subSpec.name !== command.previousName) { + const recoveryId = `${id}:recover:name`; + recoveries.push(Object.freeze({ + id: recoveryId, + property: 'name', + beforeValue: subSpec.name, + afterValue: command.name + })); + stores.set(recoveryId, { + read: () => subSpec.name, + write: name => { subSpec.name = name; } + }); + continue; + } + renames.push(Object.freeze({ + id, + beforeName: command.previousName, + afterName: command.name + })); + stores.set(id, { + read: () => subSpec.name, + write: name => { subSpec.name = name; } + }); + } + if (recoveries.length || layoutChanges.length) { + for (const rename of renames) { + const renameStore = stores.get(rename.id); + recoveries.push(Object.freeze({ + id: rename.id, + property: 'name', + beforeValue: rename.beforeName, + afterValue: rename.afterName + })); + stores.set(rename.id, renameStore); + } + return Object.freeze({ + kind: ProjectionalPolicyCacheProjectionKind.PROPERTY, + supported: true, + changes: Object.freeze([...recoveries, ...layoutChanges]), + stores, + diagnostics: Object.freeze([]) + }); + } + return Object.freeze({ + kind: ProjectionalPolicyCacheProjectionKind.RENAME, + supported: true, + renames: Object.freeze(renames), + stores, + diagnostics: Object.freeze([]) + }); + } + + projectionalRenameDiagnostic (batch, context) { + const command = batch.commands[0]; + if (batch.commands.length !== 1 || + command?.kind !== ComponentBridgeCommandKind.RENAME_NODE || + !batch.shadowProjection?.supported) return null; + const target = context.resolveMorph?.(command.nodeId); + const hasRuntimeLayoutProjection = batch.shadowProjection.steps?.[0] + ?.runtimeProjection.changeSet.operations.some(operation => + operation.property === 'layout'); + const beforeDocument = batch.shadowProjection.beforeDocument; + const semanticNodeId = batch.shadowProjection.steps?.[0] + ?.componentCommand.nodeId; + let ownerLayoutRequiresProjection = true; + if (beforeDocument instanceof ComponentDocument && semanticNodeId) { + const owner = findComponentParent(beforeDocument, semanticNodeId); + const layoutModel = owner && findComponentLayoutModel(beforeDocument, owner.id); + ownerLayoutRequiresProjection = layoutModel + ? layoutModel.references.some(reference => + reference.targetId === semanticNodeId) + : !layoutPropertyCannotReferenceChildren(owner?.properties.layout); + } + let kind = null; + if (target?.owner?.layout && ownerLayoutRequiresProjection && + !hasRuntimeLayoutProjection) { + kind = ProjectionalRenameDiagnosticKind.OWNER_LAYOUT; + } + return kind + ? Object.freeze({ + kind, + message: `Projectional rename cannot safely update the owner layout: ${kind}` + }) + : null; + } + + canRecordProjectionalEdit (legacyChanges = null) { + const undoManager = this.trackedComponent?.env?.undoManager; + if (!undoManager) return true; + if (typeof undoManager.addTransaction !== 'function') return false; + if (!undoManager.undoInProgress) return true; + if (legacyChanges === null) return true; + const recorded = undoManager.undoInProgress.recorder?.changes; + return typeof undoManager.discardRecordedChanges === 'function' && + Array.isArray(recorded) && + legacyChanges.every(change => recorded.includes(change)); + } + + recordProjectionalEditTransaction (editTransaction, legacyChanges = null) { + const undoManager = this.trackedComponent?.env?.undoManager; + if (!undoManager) return editTransaction; + if (undoManager.undoInProgress) { + if (legacyChanges !== null) { + const discarded = undoManager.discardRecordedChanges(legacyChanges); + if (discarded !== legacyChanges.length) { + throw new Error('Could not replace every recorded Morphic component change'); + } + } + undoManager.addTransaction(editTransaction, { joinActive: true }); + } else { + undoManager.addTransaction(editTransaction); + } + return editTransaction; + } + + refreshAndTrackProjectionalDependants (textRefresh = null) { + const result = this.refreshProjectionalDependants(textRefresh); + if (!result?.then) return result; + const moduleState = moduleReconciliationStateFor(this); + let pending; + pending = Promise.resolve(result).finally(() => { + if (this._pendingReconciliation === pending) this._pendingReconciliation = null; + if (moduleState?.pending === pending) moduleState.pending = null; + }); + pending.catch(() => {}); + this._pendingReconciliation = pending; + this._finishPromise = pending; + if (moduleState) { + moduleState.pending = pending; + moduleState.completion = pending; + } + return pending; + } + + refreshProjectionalMovedTarget (target, { + direction = ComponentTransactionDirection.FORWARD, + materializedNode = null, + materializedBindings = [] + } = {}) { + const root = this.trackedComponent; + if (!target || !root) return false; + const apply = () => { + if (direction === ComponentTransactionDirection.REVERSE) { + const inheritedApplicator = target.master || + target.ownerChain().find(morph => morph.master)?.master; + inheritedApplicator?.applyIfNeeded?.(true); + return true; + } + + // Component parts retain an applicator across the runtime move. Reapply + // that narrowly scoped policy, then restore explicit values captured by + // materialization. The descriptor's policy cache intentionally does not + // mirror structural source edits, so looking the node up there can select + // its suppressed inherited occurrence instead of the new local one. + target.master?.applyIfNeeded?.(true); + if (!materializedNode || + typeof root.master?.applySpecToMorph !== 'function') return true; + const applyMaterializedNode = (morph, node) => { + const properties = projectionalMaterializedProperties( + node, + materializedBindings + ); + if (!obj.isEmpty(properties)) { + root.master.applySpecToMorph(morph, properties); + } + for (const childNode of node.children) { + const child = morph.submorphs.find(candidate => + candidate.name === childNode.name + ); + if (child) applyMaterializedNode(child, childNode); + } + }; + applyMaterializedNode(target, materializedNode); + return true; + }; + const withoutRecording = () => typeof target.dontRecordChangesWhile === 'function' + ? target.dontRecordChangesWhile(apply) + : apply(); + if (typeof target.withMetaDo === 'function') { + target.withMetaDo({ + reconcileChanges: false, + origin: 'runtime-projection', + undoable: false + }, withoutRecording); + } else withoutRecording(); + return true; + } + + projectProjectionalTextIntoRuntime (root, textRefresh) { + const path = componentNodeNamePath(textRefresh.document, textRefresh.nodeId); + if (!path) return false; + let target = root; + for (const name of path) { + target = (target.submorphs || []).find(candidate => candidate.name === name); + if (!target) return false; + } + if (!target.isText) return false; + const policy = root.master; + const synthesized = policy?.synthesizeSubSpec?.(target.name, root, root); + const expected = synthesized?.textAndAttributes || ['', null]; + if (projectionalTextValueMatches(target.textAndAttributes, expected)) return false; + const textAndAttributes = materializeProjectionalTextAndAttributes(expected); + const apply = () => { target.textAndAttributes = textAndAttributes; }; + if (typeof target.withMetaDo === 'function') { + target.withMetaDo({ + reconcileChanges: false, + origin: 'runtime-projection', + undoable: false + }, apply); + } else apply(); + return true; + } + + async refreshProjectionalDependants (textRefresh = null) { + this.componentDescriptor.makeDirty(); + const refreshedRuntimes = new Set(); + if (textRefresh) { + for (const dependant of this.componentDescriptor.getDependants()) { + if (dependant === this.trackedComponent || refreshedRuntimes.has(dependant)) continue; + this.projectProjectionalTextIntoRuntime(dependant, textRefresh); + refreshedRuntimes.add(dependant); + } + } else this.componentDescriptor.refreshDependants(); + const pending = this.projectionalDerivedDescriptors(this.componentDescriptor); + const visited = new Set([this.componentDescriptor]); + while (pending.length) { + const descriptor = pending.shift(); + if (!descriptor || visited.has(descriptor)) continue; + visited.add(descriptor); + const activeComponent = descriptor?._cachedComponent; + if (activeComponent?.master && !refreshedRuntimes.has(activeComponent)) { + if (textRefresh) { + this.projectProjectionalTextIntoRuntime(activeComponent, textRefresh); + refreshedRuntimes.add(activeComponent); + pending.push(...this.projectionalDerivedDescriptors(descriptor)); + continue; + } + const apply = () => activeComponent.master.applyIfNeeded(true); + const result = typeof activeComponent.withMetaDo === 'function' + ? activeComponent.withMetaDo({ + reconcileChanges: false, + origin: 'runtime-projection', + undoable: false + }, apply) + : apply(); + await result; + } + pending.push(...this.projectionalDerivedDescriptors(descriptor)); + } + return true; + } + + scheduleShadowProjectionComparison (batch) { + if (!batch.shadowProjection?.supported) return null; + const comparisonPromise = Promise.resolve() + .then(() => this.onceChangesProcessed()) + .then(() => compareShadowProjectionToCurrentSource( + batch.shadowProjection, + this.currentModuleSource + )) + .then(comparison => this.recordShadowProjectionComparison(batch, comparison)) + .catch(error => this.recordShadowProjectionComparison(batch, Object.freeze({ + kind: ShadowProjectionComparisonKind.PROJECTION_COMPARISON_FAILED, + matches: false, + diagnostics: Object.freeze([Object.freeze({ + message: error.message, + error + })]) + }))); + this._shadowComparisonPromise = comparisonPromise; + return comparisonPromise; + } + + recordShadowProjectionComparison (batch, comparison) { + const record = Object.freeze({ + changeSetId: batch.changeSetId, + origin: batch.origin, + ...comparison + }); + this.shadowProjectionComparisons = (this.shadowProjectionComparisons || []) + .concat(record) + .slice(-100); + this.lastShadowProjectionComparison = record; + this.onShadowProjectionComparison?.(record); + return record; + } + + dispose () { + this.trackedComponent?.env.changeManager.removeCommittedChangeListener( + this._committedChangeListener + ); + if (this.trackedComponent?._changeTracker === this) { + delete this.trackedComponent._changeTracker; + } + } + /** * Returns the policy that is wrapped by the component descriptor. * @type { StylePolicy } @@ -42,8 +2246,9 @@ export class ComponentChangeTracker { * tracker is ready to reconcile changes with the module. * @returns { Promise } */ - whenReady () { - return !!this.componentModule.source(); + async whenReady () { + await this.componentModule.source(); + return true; } /** . @@ -54,7 +2259,8 @@ export class ComponentChangeTracker { * @returns { Promise } */ onceChangesProcessed () { - return this._finishPromise ? this._finishPromise.promise : Promise.resolve(true); + return moduleReconciliationStateFor(this)?.completion || + this._finishPromise || Promise.resolve(true); } /** @@ -92,10 +2298,20 @@ export class ComponentChangeTracker { * @returns { boolean } */ ignoreChange (change) { + if (this._projectionallyConsumedChanges?.has(change)) { + return true; + } + // A grouped leaf is published to morph listeners before the enclosing + // GroupChange is committed. Reconcile the group's combined operation set + // once it is complete instead of treating each premature leaf as an + // unsupported standalone edit. + if (change.group) return true; if (!change.meta?.reconcileChanges) return true; if (change.prop === 'name') return false; if (change.prop?.startsWith('_')) return true; if (change.prop === 'position' && (change.target === this.trackedComponent || this.isPositionedByLayout(change.target))) return true; + if (change.target?.isText && change.prop === 'extent' && + (change.meta?.isLayoutAction || change.meta?.metaInteraction)) return true; if (change.prop && change.prop !== 'textAndAttributes' && change.prop !== 'vertices' && @@ -135,11 +2351,17 @@ export class ComponentChangeTracker { * these changes in the source code as well as the currently initialized policy object. * @param { object } change - The change to reconcile. */ - async processChangeInComponent (change) { - if (this.ignoreChange(change)) return; - this._finishPromise = promise.deferred(); - Promise.resolve(Reconciliation.perform(this.componentDescriptor, change)).then(() => this._finishPromise.resolve()); - this.componentDescriptor.makeDirty(); - this.componentDescriptor.refreshDependants(); + processChangeInComponent (change) { + if (this.ignoreChange(change)) return Promise.resolve(true); + const moduleState = moduleReconciliationStateFor(this); + const unsupported = new ProjectionalReconciliationUnsupportedError( + change, + this._projectionalBatchesByLegacyChange?.get(change) || + this.lastShadowCommandBatch + ); + this._finishPromise = Promise.reject(unsupported); + this._finishPromise.catch(() => {}); + if (moduleState) moduleState.completion = this._finishPromise; + return this._finishPromise; } } diff --git a/lively.ide/components/component-definition.js b/lively.ide/components/component-definition.js new file mode 100644 index 0000000000..38b3a1d064 --- /dev/null +++ b/lively.ide/components/component-definition.js @@ -0,0 +1,141 @@ +import { arr, string } from 'lively.lang'; +import { parse, stringify, nodes, query } from 'lively.ast'; +import module from 'lively.modules/src/module.js'; +import { ImportInjector, ImportRemover } from 'lively.modules/src/import-modification.js'; + +import { undeclaredVariables } from '../js/import-helper.js'; +import { + convertToExpression, + DEFAULT_SKIPPED_ATTRIBUTES, + findComponentDef +} from './helpers.js'; + +function requiredBindingNames (binding) { + if (typeof binding === 'string') return { exported: binding, local: binding }; + if (binding && typeof binding.exported === 'string') { + return { exported: binding.exported, local: binding.local || binding.exported }; + } + throw new Error(`Invalid required binding: ${String(binding)}`); +} + +/** + * Generates source for a component that does not have a pre-existing source + * definition yet. + */ +export function createInitialComponentDefinition (aComponent, asExprObject = false) { + let { __expr__, bindings } = convertToExpression(aComponent, { + skipAttributes: [...DEFAULT_SKIPPED_ATTRIBUTES, 'treeData'] + }); + __expr__ = 'component(' + __expr__ + ')'; + + if (asExprObject) { + if (bindings['lively.morphic']) { + arr.pushIfNotIncluded(bindings['lively.morphic'], 'component'); + } else { + bindings['lively.morphic'] = ['component']; + } + return { __expr__, bindings }; + } + + return __expr__; +} + +/** Resolves bindings required by generated component source through imports. */ +export function fixUndeclaredVars (sourceCode, requiredBindings, mod) { + const system = mod.System; + const undeclared = undeclaredVariables(sourceCode, mod.dontTransform).map(node => node.name); + let updatedSource = sourceCode; + const changes = []; + if (undeclared.length === 0) return { updatedSource: sourceCode, changes }; + + for (const [importedModuleId, exportedIds] of requiredBindings) { + for (const requiredBinding of exportedIds) { + const { exported, local } = requiredBindingNames(requiredBinding); + if (!undeclared.includes(local)) continue; + arr.remove(undeclared, local); + let generated, from; + ({ generated, from, newSource: updatedSource } = ImportInjector.run( + system, + mod.id, + mod.package(), + updatedSource, + { + exported, + moduleId: module(system, importedModuleId).id, + pathInPackage: module(system, importedModuleId).pathInPackage(), + packageName: module(system, importedModuleId).package()?.name + }, + local === exported ? undefined : local + )); + changes.push({ action: 'insert', start: from, lines: [generated] }); + } + } + return { updatedSource, changes }; +} + +export async function removeComponentDefinition (entityName, mod) { + await mod.changeSourceAction(oldSource => { + const parsed = parse(oldSource); + const exportSpecs = query.queryNodes( + parsed, + `// ExportSpecifier [ + /:local Identifier [@name == "${entityName}"] + ], + // ExportDefaultDeclaration [ + /:declaration Identifier [@name == "${entityName}"] + ]` + ); + const rangesToRemove = []; + for (const exportSpec of exportSpecs) { + while (oldSource[exportSpec.start - 1].match(/ /)) exportSpec.start--; + while (oldSource[exportSpec.end].match(/,|\n/)) exportSpec.end++; + rangesToRemove.push({ action: 'remove', ...exportSpec }); + } + const componentDef = findComponentDef(parsed, entityName); + while (oldSource[componentDef.end].match(/,|\n/)) componentDef.end++; + rangesToRemove.push({ action: 'remove', ...componentDef }); + + return ImportRemover.removeUnusedImports( + string.applyChanges(oldSource, arr.sortBy(rangesToRemove, range => -range.start)) + ).source; + }); +} + +export async function replaceComponentDefinition (defAsCode, entityName, mod) { + await mod.changeSourceAction(oldSource => { + const { start, end } = findComponentDef(parse(oldSource), entityName); + return ImportRemover.removeUnusedImports(string.applyChanges(oldSource, [ + { start, end, action: 'replace', lines: [defAsCode] } + ])).source; + }); +} + +export async function insertComponentDefinition (protoMorph, entityName, mod) { + const scope = await mod.scope(); + await mod.changeSourceAction(oldSource => { + const { __expr__: componentCall, bindings } = createInitialComponentDefinition(protoMorph, true); + const declaration = `\nconst ${entityName} = ${componentCall};\n\n`; + const finalExports = arr.last(scope.exportDecls); + + if (!finalExports) { + return fixUndeclaredVars(oldSource + declaration, Object.entries(bindings), mod).updatedSource + + `\n\nexport { ${entityName} }`; + } + + const updatedExports = { + ...finalExports, + specifiers: [...finalExports.specifiers, nodes.id(entityName)] + }; + return System.lint(fixUndeclaredVars( + string.applyChanges(oldSource, [ + { action: 'replace', ...finalExports, lines: [declaration, stringify(updatedExports)] } + ]), + Object.entries(bindings), + mod + ).updatedSource)[0]; + }); +} + +export function canBeRenamed (mod, newName) { + return !(string.camelCaseString(newName) in mod.recorder); +} diff --git a/lively.ide/components/debug.js b/lively.ide/components/debug.js index af43fb09f8..2d3dbd3471 100644 --- a/lively.ide/components/debug.js +++ b/lively.ide/components/debug.js @@ -1,194 +1,1249 @@ -import { num, promise, string, arr } from 'lively.lang'; -import { Color, pt, Rectangle } from 'lively.graphics'; -import { Point } from 'lively.graphics/geometry-2d.js'; -import { ShadowObject, part } from 'lively.morphic'; +import { arr, obj } from 'lively.lang'; +import { Color, pt, rect } from 'lively.graphics'; +import { + add, + ConstraintLayout, + GridLayout, + morph, + part, + Polygon, + TilingLayout +} from 'lively.morphic'; +import { GroupChange } from 'lively.morphic/changes.js'; import { module } from 'lively.modules/index.js'; -import { browserForFile } from '../js/browser/ui.cp.js'; import { parse } from 'lively.ast'; +import { SeededRandom } from './reconciliation/fuzz-random.js'; +import { + ComponentNodeProvenanceKind, + findComponentNode +} from './reconciliation/component-document.js'; -/** - * The following setup performs randomized operations on a component morph, - * constantly altering the component definition. This allows us to capture - * edge cases in the source ransformation that lead to unvalid syntax or - * overly verbose component definitions. - */ +export { SeededRandom } from './reconciliation/fuzz-random.js'; +export { + ComponentProjectionFuzzer, + runComponentProjectionFuzz +} from './reconciliation/component-projection-fuzzer.js'; /** - * Randomly selects a descendant of this morph, or the morph itself if no submorphs present; - * @param { Morph } aMorph - The morph whose descendants to traverse. - * @returns { Morph } The randomly selected morph. + * Deterministic stress testing for component-to-source reconciliation. + * + * Every operation is applied through the component change tracker and the + * resulting source is parsed only after the tracker has finished. Failures + * retain the seed and the complete action trace so they can be replayed. */ -function randomSelectChild (aMorph) { - if (aMorph.submorphs.length === 0) return aMorph; - return arr.shuffle(aMorph.withAllSubmorphsDo((m) => m))[0]; -} -function generateValueFor (propSpec) { - let { defaultValue, type, values, min = 0, max = 100 } = propSpec; - if (!defaultValue && !type) return; - if (!type) { - type = defaultValue.constructor?.name; - if (!type) return; - } - switch (type) { - case 'Boolean': return !!num.random(0, 1); - case 'String': return string.newUUID(); - case 'Enum': return arr.shuffle(values)[0]; - case 'Color': return Color.random(); - // ColorGradient, - // Layout, - case 'Rectangle': return Rectangle.fromAny(Point.random(pt(500, 500)), Point.random(pt(500, 500))); - case 'Number': return num.random(min, max) || num.random(); - case 'Shadow': return new ShadowObject({}); - case 'Point': return Point.random(pt(500, 500)); - } -} +export const DEFAULT_RECONCILIATION_FUZZ_SEED = 0xC0FFEE; -/** - * Generates a randomized set of properties that can be applied to the given morph. - * @param { Morph } aMorph - The morph to generate the props for. - */ -function generatePropsFor (aMorph) { - const props = aMorph.propertiesAndPropertySettings().properties; - const { styleProperties } = aMorph; - const selectedProps = arr.shuffle(styleProperties).slice(0, num.random(0, styleProperties.length / 4)); - const generated = {}; - for (let propName of selectedProps) { - generated[propName] = generateValueFor(props[propName]); - if (typeof generated[propName] === 'undefined') delete generated[propName]; - } - return generated; -} +export const RECONCILIATION_FUZZ_STABLE_STYLE_PROPERTIES = Object.freeze([ + 'fill', + 'borderColor', + 'borderWidth', + 'borderStyle', + 'borderRadius', + 'opacity', + 'visible', + 'scale', + 'rotation', + 'tooltip', + 'origin', + 'clipMode' +]); -let editableComponent; -const testModuleId = 'local://lively-object-modules/Test/component-monkey-patch-test-a.cp.js'; -const initSource = ` -import { part, component, ComponentDescriptor } from 'lively.morphic/components/core.js'; -import { InteractiveComponentDescriptor } from 'lively.ide/components/editor.js'; -import { Color, pt} from 'lively.graphics'; -import { Text } from "lively.morphic"; +export const RECONCILIATION_FUZZ_OPERATIONS = [ + 'addPlainMorph', + 'addPart', + 'addModelPart', + 'addPartWithNestedAddition', + 'removeMorph', + 'reintroduceMorph', + 'cycleInheritedSuppression', + 'reparentMorph', + 'reparentInheritedMorph', + 'renameMorph', + 'renameInheritedMorph', + 'reorderMorph', + 'setProperties', + 'batchPropertyAndStructure', + 'batchRenameAndLayout', + 'resetProperty', + 'changeText', + 'editTextRange', + 'burstTextEdits', + 'changeRichText', + 'insertEmbeddedMorph', + 'removeEmbeddedMorph', + 'updateEmbeddedMorph', + 'changeLayout', + 'changeLayoutKind', + 'changeDetailedTilingLayout', + 'changeLayoutPolicies', + 'changeMaster', + 'clearMasterState', + 'addPolygon', + 'changeVertices', + 'addNameCollision', + 'addScopedNameCollision', + 'undoTransaction', + 'redoTransaction' +]; -component.DescriptorClass = InteractiveComponentDescriptor; +export const KNOWN_BROKEN_RECONCILIATION_FUZZ_OPERATIONS = []; -const C = component({ - fill: Color.grey, -}); +export const DEFAULT_RECONCILIATION_FUZZ_OPERATIONS = RECONCILIATION_FUZZ_OPERATIONS.filter( + operation => !KNOWN_BROKEN_RECONCILIATION_FUZZ_OPERATIONS.includes(operation) +); -const D = component({ +const defaultBaseModuleId = 'local://lively-object-modules/Test/component-reconciliation-fuzz-base.cp.js'; +const defaultSubjectModuleId = 'local://lively-object-modules/Test/component-reconciliation-fuzz-subject.cp.js'; + +export const reconciliationFuzzBaseSource = ` +import { component, ComponentDescriptor, part, ViewModel } from 'lively.morphic/components/core.js'; +import { Color, pt } from 'lively.graphics'; +import { Text } from 'lively.morphic'; + +component.DescriptorClass = ComponentDescriptor; + +class FuzzViewModel extends ViewModel { + static get properties () { + return { label: { defaultValue: 'base' } }; + } +} + +const Leaf = component({ + name: 'Fuzz Leaf', fill: Color.purple, submorphs: [{ - name: 'a deep morph', + name: 'leaf child', fill: Color.orange }] }); -const A = component({ +const Base = component({ + name: 'Fuzz Base', fill: Color.red, - extent: pt(100,100), + extent: pt(180, 120), submorphs: [{ type: Text, - name: 'some submorph', - extent: pt(50,50), + name: 'fuzz text', + textString: 'initial text', + extent: pt(100, 30), fixedWidth: true, fixedHeight: true, - fill: Color.yellow, - },part(D, { name: 'some ref'})] + readOnly: false, + selectable: true, + reactsToPointer: true + }, part(Leaf, { name: 'fuzz leaf part' })] }); -const Monkey = component(A, { +const ModelPart = component({ + name: 'Fuzz Model Part', + defaultViewModel: FuzzViewModel, + viewModel: { label: 'base' }, + fill: Color.blue +}); + +export { Base, Leaf, ModelPart }; +`; + +export function reconciliationFuzzSubjectSource (baseModuleId = defaultBaseModuleId) { + return ` +import { component, ComponentDescriptor } from 'lively.morphic/components/core.js'; +import { InteractiveComponentDescriptor } from 'lively.ide/components/editor.js'; +import { Color } from 'lively.graphics'; +import { Base as AliasedBase, Leaf as AliasedLeaf, ModelPart } from '${baseModuleId}'; + +component.DescriptorClass = InteractiveComponentDescriptor; + +const Subject = component(AliasedBase, { + name: 'Fuzz Subject', submorphs: [{ - name: 'some submorph', + name: 'fuzz text', fill: Color.green + }, { + name: 'fuzz leaf part', + submorphs: [{ + name: 'leaf child', + borderWidth: 2 + }] }] }); component.DescriptorClass = ComponentDescriptor; -export { A, C, D, Monkey }; +export { Subject }; `; +} -async function ensureModule () { - let testComponentModule = module(testModuleId); +function componentName (component) { + return component?.[Symbol.for('lively-module-meta')]?.exportedName || component?.name; +} - await testComponentModule.reset(); - if (testComponentModule.format() === 'global') { - await testComponentModule.changeSource('', { moduleId: testModuleId }); - await testComponentModule.reload(); - await testComponentModule.setFormat('register'); - await testComponentModule.changeSource(initSource, { moduleId: testModuleId }); - await testComponentModule.reload(); - } else { - // reset the module to its original code - await testComponentModule.changeSource(initSource, { moduleId: testModuleId }); +function printableValue (value) { + if (value === null || typeof value !== 'object') return String(value); + if (value.isPoint || value.isColor) return value.toString(); + if (value.isLayout) return value.constructor.name; + return value.constructor?.name || 'Object'; +} + +export class ReconciliationFuzzError extends Error { + constructor (message, details, cause) { + const trace = JSON.stringify(details.actions, null, 2); + const fullMessage = `${message}\nseed: ${details.seed}\nstep: ${details.step}\noperation: ${details.operation}\ntrace: ${trace}`; + super(fullMessage); + this.name = 'ReconciliationFuzzError'; + this.message = fullMessage; + this.cause = cause; + Object.assign(this, details); } } -let Monkey, A, C, D; +export class ReconciliationFuzzer { + constructor ({ + component, + componentDescriptor, + components, + subjectModule, + seed = DEFAULT_RECONCILIATION_FUZZ_SEED, + operations = DEFAULT_RECONCILIATION_FUZZ_OPERATIONS, + validateSource, + maxSourceGrowthPerStep = 5000 + }) { + this.component = component; + this.componentDescriptor = componentDescriptor; + this.components = components; + this.subjectModule = subjectModule; + this.seed = seed; + this.random = new SeededRandom(seed); + this.operations = operations.slice(); + this.operationQueue = []; + this.validateSource = validateSource; + this.maxSourceGrowthPerStep = maxSourceGrowthPerStep; + this.actions = []; + this.removedMorphs = []; + this.propertyHistory = []; + this.nameCounter = 0; + this.initialSourceLength = null; + this.initialUndoCount = component.env?.undoManager?.undos.length || 0; + } -async function createSetup () { - await ensureModule(); - const testComponentModule = module(testModuleId); - ({ Monkey, A, C, D } = await testComponentModule.load()); - editableComponent = await Monkey.edit(); - return await $world.execCommand('open browser', { moduleName: testModuleId, packageName: 'Test', reuse: true }); -} + allMorphs () { + return this.component.withAllSubmorphsDo(morph => morph); + } -// performNextChange() - -async function performNextChange () { - // pick between adding a new morph, - // removing a morph - // or changing a prop - const target = randomSelectChild(editableComponent); - switch (arr.shuffle(['add', 'remove', 'prop'])[0]) { - case 'add': - let newChild = part(arr.shuffle([A, C, D])[0], { name: string.newUUID() }); - editableComponent.withMetaDo({ reconcileChanges: true }, () => { - target.addMorph(newChild, arr.shuffle(target.submorphs)[0]); - }); - return ['add', target, newChild]; - break; - case 'remove': - if (target !== editableComponent) { - const ownerChain = target.ownerChain().map(m => m.name); - editableComponent.withMetaDo({ reconcileChanges: true }, () => { - target.remove(); - }); - return ['remove', target, ownerChain]; + isEmbeddedTextMorph (morph) { + let current = morph; + while (current && current !== this.component) { + const owner = current.owner; + if (owner?.isText && owner.textAndAttributes?.includes(current)) return true; + current = owner; + } + return false; + } + + componentMorphs () { + return this.allMorphs().filter(morph => !this.isEmbeddedTextMorph(morph)); + } + + semanticProvenanceKind (morph) { + const tracker = this.component._changeTracker; + const resolution = tracker?.resolveProjectionalCommandTarget?.(morph); + if (!resolution || resolution.committed === false) return null; + return findComponentNode(resolution.document, resolution.nodeId)?.provenance.kind || null; + } + + isProjectionallyAdded (morph) { + return this.semanticProvenanceKind(morph) === ComponentNodeProvenanceKind.ADDED; + } + + isProjectionallyInherited (morph) { + return this.semanticProvenanceKind(morph) === ComponentNodeProvenanceKind.INHERITED; + } + + isAttached (aMorph) { + return aMorph === this.component || this.allMorphs().includes(aMorph); + } + + pathOf (aMorph) { + if (aMorph === this.component) return []; + const path = []; + let current = aMorph; + while (current && current !== this.component) { + path.unshift(current.name); + current = current.owner; + } + return path; + } + + nextName (prefix) { + const suffix = this.random.pick(['', " 'quoted'", ' "double"', ' \\backslash']); + return `${prefix} ${++this.nameCounter}${suffix}`; + } + + randomOwner () { + return this.random.pick(this.componentMorphs().filter(morph => !morph.isText)); + } + + insertionPointFor (owner) { + if (!owner.submorphs.length || this.random.boolean()) return null; + return this.random.pick(owner.submorphs); + } + + reconcile (callback, grouped = false) { + const reconcile = () => + this.component.withMetaDo({ reconcileChanges: true }, callback); + return grouped + ? this.component.groupChangesWhile(new GroupChange(this.component), reconcile) + : reconcile(); + } + + nextOperation () { + if (!this.operationQueue.length) this.operationQueue = this.random.shuffle(this.operations); + return this.operationQueue.shift(); + } + + chooseAndPerformOperation () { + for (let attempts = 0; attempts < this.operations.length; attempts++) { + const operation = this.nextOperation(); + this.selectedOperation = operation; + const action = this[operation](); + if (action) return { operation, action }; + } + throw new Error('No reconciliation fuzz operation is currently applicable'); + } + + addPlainMorph () { + const owner = this.randomOwner(); + if (!owner) return null; + const name = this.nextName('plain'); + const submorphs = this.random.boolean(0.4) + ? [morph({ name: this.nextName('nested'), fill: Color.orange })] + : []; + const addedMorph = morph({ + name, + fill: this.random.pick([Color.cyan, Color.orange, Color.purple]), + extent: pt(this.random.integer(20, 140), this.random.integer(20, 140)), + submorphs + }); + const before = this.insertionPointFor(owner); + const ownerPath = this.pathOf(owner); + this.reconcile(() => owner.addMorph(addedMorph, before)); + return { kind: 'addPlainMorph', ownerPath, name, before: before?.name || null, nested: submorphs.length > 0 }; + } + + addPart () { + const owner = this.component; + const descriptor = this.random.pick([this.components.base, this.components.nested]); + if (!owner || !descriptor) return null; + const name = this.nextName('part'); + const addedPart = part(descriptor, { name }); + const before = this.insertionPointFor(owner); + const ownerPath = this.pathOf(owner); + this.reconcile(() => owner.addMorph(addedPart, before)); + return { + kind: 'addPart', + ownerPath, + component: componentName(descriptor), + name, + before: before?.name || null + }; + } + + addModelPart () { + const owner = this.component; + const descriptor = this.components.model; + if (!owner || !descriptor) return null; + const name = this.nextName('model part'); + const viewModel = { + label: this.nextName('model'), + flags: [true, false, this.random.integer(0, 10)], + nested: { enabled: this.random.boolean() } + }; + const addedPart = part(descriptor, { name, viewModel }); + const before = this.insertionPointFor(owner); + const ownerPath = this.pathOf(owner); + this.reconcile(() => owner.addMorph(addedPart, before)); + return { + kind: 'addModelPart', + ownerPath, + component: componentName(descriptor), + name, + before: before?.name || null, + viewModel + }; + } + + addPartWithNestedAddition () { + const owner = this.component; + const descriptor = this.components.nested; + if (!owner || !descriptor) return null; + const name = this.nextName('nested part'); + const nestedName = this.nextName('part addition'); + const addedPart = part(descriptor, { + name, + submorphs: [add({ name: nestedName, fill: Color.cyan })] + }); + const before = this.insertionPointFor(owner); + const ownerPath = this.pathOf(owner); + this.reconcile(() => owner.addMorph(addedPart, before)); + return { + kind: 'addPartWithNestedAddition', + ownerPath, + component: componentName(descriptor), + name, + nestedName, + before: before?.name || null + }; + } + + removeMorph () { + const candidates = this.componentMorphs() + .filter(morph => morph !== this.component && !morph.owner?.isText); + const target = this.random.pick(candidates); + if (!target) return null; + const path = this.pathOf(target); + this.reconcile(() => target.remove()); + this.removedMorphs.push({ morph: target, path }); + return { kind: 'removeMorph', path }; + } + + reintroduceMorph () { + const candidates = this.removedMorphs.filter(({ morph }) => !this.isAttached(morph)); + const removed = this.random.pick(candidates); + if (!removed) return null; + const owner = this.randomOwner(); + if (!owner) return null; + arr.remove(this.removedMorphs, removed); + const { morph: removedMorph, path: previousPath } = removed; + if (this.random.boolean()) removedMorph.fill = this.random.pick([Color.green, Color.orange, Color.purple]); + const before = this.insertionPointFor(owner); + const ownerPath = this.pathOf(owner); + this.reconcile(() => owner.addMorph(removedMorph, before)); + return { + kind: 'reintroduceMorph', + previousPath, + ownerPath, + name: removedMorph.name, + before: before?.name || null + }; + } + + cycleInheritedSuppression () { + const target = this.random.pick(this.componentMorphs().filter(morph => + morph !== this.component && + this.isProjectionallyInherited(morph) && + !morph.owner?.isText + )); + if (!target) return null; + const owner = target.owner; + const index = owner.submorphs.indexOf(target); + const before = owner.submorphs[index + 1] || null; + const path = this.pathOf(target); + this.reconcile(() => { + target.remove(); + owner.addMorph(target, before); + }); + return { + kind: 'cycleInheritedSuppression', + path, + before: before?.name || null + }; + } + + reparentMorphMatching (kind, matchesTarget) { + const morphs = this.componentMorphs(); + const targets = morphs.filter(target => + target !== this.component && + matchesTarget(target) && + !target.owner?.isText + ); + const target = this.random.pick(targets.filter(candidate => morphs.some(owner => { + if (owner.isText || owner === candidate.owner) return false; + for (let current = owner; current; current = current.owner) { + if (current === candidate) return false; } - case 'prop': - let props = generatePropsFor(target); - editableComponent.withMetaDo({ reconcileChanges: true }, () => { - Object.assign(target, props); - }); - return ['apply props', target, props]; + return true; + }))); + if (!target) return null; + + const owners = morphs.filter(owner => { + if (owner.isText || owner === target.owner) return false; + for (let current = owner; current; current = current.owner) { + if (current === target) return false; + } + return true; + }); + const newOwner = this.random.pick(owners); + if (!newOwner) return null; + + const previousPath = this.pathOf(target); + const newOwnerPath = this.pathOf(newOwner); + const before = this.insertionPointFor(newOwner); + this.reconcile(() => newOwner.addMorph(target, before)); + return { + kind, + previousPath, + newOwnerPath, + name: target.name, + before: before?.name || null + }; + } + + reparentMorph () { + return this.reparentMorphMatching( + 'reparentMorph', + target => this.isProjectionallyAdded(target) + ); + } + + reparentInheritedMorph () { + return this.reparentMorphMatching( + 'reparentInheritedMorph', + target => this.isProjectionallyInherited(target) + ); + } + + renameMorph () { + const candidates = this.componentMorphs().filter(morph => + morph !== this.component && this.isProjectionallyAdded(morph) + ); + const target = this.random.pick(candidates); + if (!target) return null; + const path = this.pathOf(target); + const oldName = target.name; + const newName = this.nextName('renamed'); + this.reconcile(() => { target.name = newName; }); + return { kind: 'renameMorph', path, oldName, newName }; + } + + renameInheritedMorph () { + const candidates = this.componentMorphs().filter(morph => + morph !== this.component && + this.isProjectionallyInherited(morph) && + !morph.owner?.isText + ); + const target = this.random.pick(candidates); + if (!target) return null; + const path = this.pathOf(target); + const oldName = target.name; + const newName = this.nextName('renamed inherited'); + this.reconcile(() => { target.name = newName; }); + return { kind: 'renameInheritedMorph', path, oldName, newName }; + } + + reorderMorph () { + const owners = this.componentMorphs().filter(morph => + !morph.isText && + morph.submorphs.length > 1 && + morph.submorphs.some(submorph => + this.isProjectionallyAdded(submorph) && !submorph.master) + ); + const owner = this.random.pick(owners); + if (!owner) return null; + const ownerPath = this.pathOf(owner); + const child = this.random.pick(owner.submorphs.filter(submorph => + this.isProjectionallyAdded(submorph) && !submorph.master + )); + let before = null; + if (owner.submorphs.indexOf(child) === owner.submorphs.length - 1) { + before = owner.submorphs[0]; + } + this.reconcile(() => owner.addMorph(child, before)); + return { kind: 'reorderMorph', ownerPath, name: child.name, before: before?.name || null }; + } + + valueForProperty (property) { + switch (property) { + case 'fill': return this.random.pick([Color.red, Color.green, Color.blue, Color.orange, Color.transparent]); + case 'borderWidth': return this.random.integer(0, 20); + case 'extent': return pt(this.random.integer(20, 200), this.random.integer(20, 200)); + case 'position': return pt(this.random.integer(-100, 300), this.random.integer(-100, 300)); + case 'scale': return this.random.integer(2, 21) / 10; + case 'opacity': return this.random.integer(1, 11) / 10; + case 'visible': return this.random.boolean(); + case 'rotation': return this.random.integer(-6, 7) / 4; + case 'tooltip': return this.nextName('tooltip'); + } + } + + setProperties () { + const propertyNames = [ + 'fill', + 'borderWidth', + 'extent', + 'position', + 'scale', + 'opacity', + 'visible', + 'rotation', + 'tooltip' + ]; + const propertiesFor = morph => propertyNames.filter(property => + morph.styleProperties.includes(property) && + !(property === 'position' && ( + morph === this.component || + this.component._changeTracker?.isPositionedByLayout(morph) + )) + ); + const target = this.random.pick(this.componentMorphs().filter(morph => + propertiesFor(morph).length > 0 + )); + if (!target) return null; + const selectedProperties = this.random.shuffle(propertiesFor(target)) + .slice(0, this.random.boolean() ? 1 : 2); + const changes = selectedProperties.map(property => ({ + property, + previous: target[property], + value: this.valueForProperty(property) + })); + const path = this.pathOf(target); + this.reconcile(() => { + for (const change of changes) target[change.property] = change.value; + }, true); + this.propertyHistory.push(...changes.map(change => ({ target, ...change }))); + return { + kind: 'setProperties', + path, + changes: changes.map(({ property, value }) => ({ property, value: printableValue(value) })) + }; + } + + batchPropertyAndStructure () { + const owner = this.randomOwner(); + if (!owner) return null; + const name = this.nextName('batched child'); + const addedMorph = morph({ + name, + fill: this.random.pick([Color.cyan, Color.orange, Color.purple]), + position: this.valueForProperty('position'), + extent: this.valueForProperty('extent') + }); + const property = this.random.pick(['fill', 'opacity', 'rotation']); + const previous = owner[property]; + const value = this.valueForProperty(property); + const before = this.insertionPointFor(owner); + const ownerPath = this.pathOf(owner); + this.reconcile(() => { + owner[property] = value; + owner.addMorph(addedMorph, before); + }); + this.propertyHistory.push({ target: owner, property, previous, value }); + return { + kind: 'batchPropertyAndStructure', + ownerPath, + name, + before: before?.name || null, + property, + value: printableValue(value) + }; + } + + batchRenameAndLayout () { + const owners = this.componentMorphs().filter(morph => + !morph.isText && + morph.styleProperties.includes('layout') && + morph.submorphs.some(submorph => this.isProjectionallyAdded(submorph)) + ); + const owner = this.random.pick(owners); + if (!owner) return null; + const target = this.random.pick(owner.submorphs.filter(submorph => + this.isProjectionallyAdded(submorph) + )); + if (!target) return null; + const ownerPath = this.pathOf(owner); + const targetPath = this.pathOf(target); + const oldName = target.name; + const newName = this.nextName('batched rename'); + const previous = owner.layout; + const layout = new TilingLayout({ + axis: this.random.pick(['row', 'column']), + spacing: this.random.integer(0, 20), + renderViaCSS: false + }); + this.reconcile(() => { + owner.layout = layout; + target.name = newName; + }, true); + this.propertyHistory.push({ target: owner, property: 'layout', previous, value: layout }); + return { + kind: 'batchRenameAndLayout', + ownerPath, + targetPath, + oldName, + newName, + axis: layout.axis, + spacing: layout.spacing + }; + } + + resetProperty () { + const candidates = this.propertyHistory.filter(change => + this.isAttached(change.target) && !obj.equals(change.target[change.property], change.previous) + ); + const change = this.random.pick(candidates); + if (!change) return null; + arr.remove(this.propertyHistory, change); + const path = this.pathOf(change.target); + this.reconcile(() => { change.target[change.property] = change.previous; }); + return { + kind: 'resetProperty', + path, + property: change.property, + value: printableValue(change.previous) + }; } -} -// errorChange = await runSteps(1000); -// errorChange[1].name -// errorChange[2] -// errorChange[3] - -async function runSteps (n) { - let sourceBefore; - const b = await createSetup(); - await b.whenRendered(); - const editor = b.get('source editor'); - editor.scrollPageDown(); - await b.whenRendered(); - while (n-- > 0) { - await editor.whenRendered(); - sourceBefore = editor.textString; - const change = await performNextChange(); + changeText () { + const target = this.random.pick(this.componentMorphs().filter(morph => morph.isText)); + if (!target) return null; + const path = this.pathOf(target); + const text = this.nextName('text'); + this.reconcile(() => { target.textAndAttributes = [text, null]; }); + return { kind: 'changeText', path, text }; + } + + editTextRange () { + const target = this.random.pick(this.componentMorphs().filter(morph => + morph.isText && + !morph.readOnly && + morph.selectable && + morph.reactsToPointer && + morph.document && + !morph.textString.includes('\n') + )); + if (!target) return null; + const path = this.pathOf(target); + const length = target.textString.length; + const start = this.random.integer(0, length + 1); + const end = this.random.integer(start, length + 1); + const replacement = this.nextName('range'); + const attributes = this.random.boolean() + ? { + fontWeight: this.random.pick(['bold', 'normal']), + textColor: this.random.pick([Color.red, Color.green, Color.blue]) + } + : null; + this.reconcile(() => { + target.replace({ + start: { row: 0, column: start }, + end: { row: 0, column: end } + }, [replacement, attributes], false, true, true); + }); + return { kind: 'editTextRange', path, start, end, replacement }; + } + + burstTextEdits () { + const target = this.random.pick(this.componentMorphs().filter(morph => morph.isText)); + if (!target) return null; + const path = this.pathOf(target); + const texts = Array.from({ length: 3 }, () => this.nextName('burst')); + this.reconcile(() => { + for (const text of texts) target.textAndAttributes = [text, null]; + }); + return { kind: 'burstTextEdits', path, texts }; + } + + changeRichText () { + const target = this.random.pick(this.componentMorphs().filter(morph => morph.isText)); + if (!target) return null; + const path = this.pathOf(target); + const text = this.nextName('rich text'); + const embeddedName = this.nextName('embedded'); + const embeddedMorph = morph({ + name: embeddedName, + fill: this.random.pick([Color.cyan, Color.orange, Color.purple]), + extent: pt(this.random.integer(8, 40), this.random.integer(8, 40)) + }); + const attributes = { + fontWeight: this.random.pick(['bold', 'normal']), + textColor: this.random.pick([Color.red, Color.green, Color.blue]) + }; + this.reconcile(() => { + target.textAndAttributes = [ + `${text} before `, attributes, + embeddedMorph, null, + ' after', null + ]; + }); + return { kind: 'changeRichText', path, text, embeddedName }; + } + + insertEmbeddedMorph () { + const target = this.random.pick(this.componentMorphs().filter(morph => morph.isText)); + if (!target) return null; + const path = this.pathOf(target); + const embeddedName = this.nextName('inserted embedded'); + const embeddedMorph = morph({ + name: embeddedName, + fill: this.random.pick([Color.cyan, Color.orange, Color.purple]), + extent: pt(this.random.integer(8, 40), this.random.integer(8, 40)) + }); + const previous = target.textAndAttributes.slice(); + const insertionIndex = this.random.integer(0, previous.length / 2 + 1) * 2; + const next = previous.slice(); + next.splice(insertionIndex, 0, embeddedMorph, null); + this.reconcile(() => { target.textAndAttributes = next; }); + return { kind: 'insertEmbeddedMorph', path, embeddedName, insertionIndex }; + } + + removeEmbeddedMorph () { + const candidates = this.componentMorphs() + .filter(morph => morph.isText) + .flatMap(textMorph => textMorph.textAndAttributes + .map((value, index) => ({ textMorph, embeddedMorph: value, index })) + .filter(({ embeddedMorph, index }) => embeddedMorph?.isMorph && index % 2 === 0)); + const candidate = this.random.pick(candidates); + if (!candidate) return null; + const { textMorph, embeddedMorph, index } = candidate; + const path = this.pathOf(textMorph); + const next = textMorph.textAndAttributes.slice(); + next.splice(index, 2); + this.reconcile(() => { textMorph.textAndAttributes = next; }); + return { + kind: 'removeEmbeddedMorph', + path, + embeddedName: embeddedMorph.name, + index + }; + } + + updateEmbeddedMorph () { + const candidates = this.componentMorphs() + .filter(morph => morph.isText) + .flatMap(textMorph => textMorph.textAndAttributes + .filter(value => value?.isMorph) + .map(embeddedMorph => ({ textMorph, embeddedMorph }))); + const candidate = this.random.pick(candidates); + if (!candidate) return null; + const { textMorph, embeddedMorph } = candidate; + const path = this.pathOf(textMorph); + const fill = this.random.pick([Color.red, Color.green, Color.blue, Color.orange]); + const replacement = morph({ + ...embeddedMorph.spec(), + name: embeddedMorph.name, + fill + }); + this.reconcile(() => { + textMorph.textAndAttributes = textMorph.textAndAttributes + .map(value => value === embeddedMorph ? replacement : value); + }); + return { + kind: 'updateEmbeddedMorph', + path, + name: embeddedMorph.name, + fill: printableValue(fill) + }; + } + + changeLayout () { + const target = this.random.pick(this.componentMorphs().filter(morph => + !morph.isText && morph.styleProperties.includes('layout') + )); + if (!target) return null; + const path = this.pathOf(target); + const spacing = this.random.integer(0, 20); + const previous = target.layout; + const layout = new TilingLayout({ spacing, renderViaCSS: false }); + this.reconcile(() => { target.layout = layout; }); + this.propertyHistory.push({ target, property: 'layout', previous, value: layout }); + return { kind: 'changeLayout', path, spacing }; + } + + changeLayoutKind () { + const target = this.random.pick(this.componentMorphs().filter(morph => + !morph.isText && morph.styleProperties.includes('layout') + )); + if (!target) return null; + const path = this.pathOf(target); + const previous = target.layout; + const availableKinds = ['none', 'tiling', 'constraint', 'grid'] + .filter(kind => kind !== ( + previous instanceof TilingLayout + ? 'tiling' + : previous instanceof ConstraintLayout + ? 'constraint' + : previous instanceof GridLayout ? 'grid' : 'none' + )); + const kind = this.random.pick(availableKinds); + const layout = kind === 'tiling' + ? new TilingLayout({ spacing: this.random.integer(0, 20), renderViaCSS: false }) + : kind === 'constraint' + ? new ConstraintLayout({ renderViaCSS: false }) + : kind === 'grid' + ? new GridLayout({ + autoAssign: true, + columnCount: Math.max(1, Math.min(3, target.submorphs.length || 1)), + rowCount: Math.max(1, Math.ceil(target.submorphs.length / 3)), + renderViaCSS: false + }) + : null; + this.reconcile(() => { target.layout = layout; }); + this.propertyHistory.push({ target, property: 'layout', previous, value: layout }); + return { kind: 'changeLayoutKind', path, layoutKind: kind }; + } + + changeDetailedTilingLayout () { + const target = this.random.pick(this.componentMorphs().filter(morph => + !morph.isText && morph.styleProperties.includes('layout') + )); + if (!target) return null; + const path = this.pathOf(target); + const previous = target.layout; + const layout = new TilingLayout({ + axis: this.random.pick(['row', 'column']), + align: this.random.pick(['left', 'center', 'right']), + axisAlign: this.random.pick(['left', 'center', 'right']), + justifySubmorphs: this.random.pick(['packed', 'spaced']), + padding: rect( + this.random.integer(0, 10), + this.random.integer(0, 10), + this.random.integer(0, 10), + this.random.integer(0, 10) + ), + spacing: this.random.integer(0, 20), + orderByIndex: this.random.boolean(), + wrapSubmorphs: this.random.boolean(), + renderViaCSS: false + }); + this.reconcile(() => { target.layout = layout; }); + this.propertyHistory.push({ target, property: 'layout', previous, value: layout }); + return { + kind: 'changeDetailedTilingLayout', + path, + axis: layout.axis, + align: layout.align, + axisAlign: layout.axisAlign, + justifySubmorphs: layout.justifySubmorphs, + spacing: layout.spacing, + wrapSubmorphs: layout.wrapSubmorphs + }; + } + + changeLayoutPolicies () { + const target = this.random.pick(this.componentMorphs().filter(morph => + !morph.isText && + morph.styleProperties.includes('layout') && + morph.submorphs.length > 0 + )); + if (!target) return null; + const path = this.pathOf(target); + const spacing = this.random.integer(0, 20); + const resizePolicies = target.submorphs.map(submorph => [ + submorph.name, + { + width: this.random.pick(['fixed', 'fill']), + height: this.random.pick(['fixed', 'fill']) + } + ]); + const previous = target.layout; + const layout = new TilingLayout({ + spacing, + resizePolicies, + orderByIndex: this.random.boolean(), + wrapSubmorphs: this.random.boolean(), + renderViaCSS: false + }); + this.reconcile(() => { target.layout = layout; }); + this.propertyHistory.push({ target, property: 'layout', previous, value: layout }); + return { + kind: 'changeLayoutPolicies', + path, + spacing, + resizePolicies + }; + } + + changeMaster () { + const target = this.random.pick(this.componentMorphs().filter(morph => morph.master)); + const descriptor = this.random.pick(this.components); + if (!target || !descriptor) return null; + const path = this.pathOf(target); + const state = this.random.pick(['hover', 'click']); + const policy = target.master.copy(); + policy.applyConfiguration({ ...(policy.getConfig() || {}), [state]: descriptor }); + policy.attach(target); + this.reconcile(() => { target.setProperty('master', policy); }); + return { kind: 'changeMaster', path, state, component: componentName(descriptor) }; + } + + clearMasterState () { + const target = this.random.pick(this.componentMorphs().filter(morph => { + const config = morph.master?.getConfig?.(); + return config?.hover || config?.click; + })); + if (!target) return null; + const path = this.pathOf(target); + const policy = target.master.copy(); + const config = policy.getConfig() || {}; + const availableStates = ['hover', 'click'].filter(state => config[state]); + const state = this.random.pick(availableStates); + if (!state) return null; + const nextConfig = { ...config }; + delete nextConfig[state]; + const hasRemainingConfiguration = Object.keys(nextConfig).length > 0; + if (hasRemainingConfiguration) { + policy.reset(); + policy.applyConfiguration(nextConfig); + policy.attach(target); + } + this.reconcile(() => { + target.setProperty('master', hasRemainingConfiguration ? policy : null); + }); + return { kind: 'clearMasterState', path, state }; + } + + addPolygon () { + const owner = this.randomOwner(); + if (!owner) return null; + const name = this.nextName('polygon'); + const vertices = [ + pt(0, 0), + pt(this.random.integer(20, 100), 0), + pt(this.random.integer(10, 80), this.random.integer(20, 100)) + ]; + const polygon = new Polygon({ + name, + vertices, + fill: this.random.pick([Color.cyan, Color.orange, Color.purple]) + }); + const before = this.insertionPointFor(owner); + const ownerPath = this.pathOf(owner); + this.reconcile(() => owner.addMorph(polygon, before)); + return { kind: 'addPolygon', ownerPath, name, before: before?.name || null }; + } + + changeVertices () { + const target = this.random.pick(this.componentMorphs().filter(morph => + morph.isPolygon && this.isProjectionallyAdded(morph) + )); + if (!target) return null; + const path = this.pathOf(target); + const previous = target.vertices; + const vertices = [ + pt(0, 0), + pt(this.random.integer(20, 120), this.random.integer(0, 30)), + pt(this.random.integer(30, 100), this.random.integer(40, 130)), + pt(this.random.integer(0, 20), this.random.integer(30, 100)) + ]; + this.reconcile(() => { target.vertices = vertices; }); + this.propertyHistory.push({ target, property: 'vertices', previous, value: vertices }); + return { kind: 'changeVertices', path, vertices: vertices.map(String) }; + } + + addNameCollision () { + const owner = this.component; + if (!owner) return null; + const requestedName = this.nextName('collision'); + const first = morph({ name: requestedName, fill: Color.cyan }); + const second = morph({ name: requestedName, fill: Color.orange }); + const ownerPath = this.pathOf(owner); + this.reconcile(() => { + owner.addMorph(first); + owner.addMorph(second); + }); + return { kind: 'addNameCollision', ownerPath, requestedName }; + } + + addScopedNameCollision () { + const owner = this.component; + const descriptor = this.components.nested; + if (!owner || !descriptor) return null; + const requestedName = this.nextName('scoped collision'); + const firstScopeName = this.nextName('collision scope'); + const secondScopeName = this.nextName('collision scope'); + const firstPart = part(descriptor, { + name: firstScopeName, + submorphs: [add({ name: requestedName, fill: Color.cyan })] + }); + const secondPart = part(descriptor, { + name: secondScopeName, + submorphs: [add({ name: requestedName, fill: Color.orange })] + }); + const ownerPath = this.pathOf(owner); + this.reconcile(() => { + owner.addMorph(firstPart); + owner.addMorph(secondPart); + }); + return { + kind: 'addScopedNameCollision', + ownerPath, + component: componentName(descriptor), + requestedName, + scopeNames: [firstScopeName, secondScopeName] + }; + } + + undoTransaction () { + const undoManager = this.component.env?.undoManager; + if (!undoManager || undoManager.undos.length <= this.initialUndoCount) return null; + const transaction = undoManager.undo(); + if (!transaction) return null; + // A redo is only meaningful immediately after an undo. Schedule it next + // instead of leaving coverage to a shuffled turn where later edits may + // already have cleared the redo stack. + this.operationQueue = this.operationQueue.filter( + operation => operation !== 'redoTransaction' + ); + this.operationQueue.unshift('redoTransaction'); + return { + kind: 'undoTransaction', + transaction: transaction.label || transaction.name || transaction.constructor.name + }; + } + + redoTransaction () { + const undoManager = this.component.env?.undoManager; + if (!undoManager?.redos.length) return null; + const transaction = undoManager.redo(); + if (!transaction) return null; + return { + kind: 'redoTransaction', + transaction: transaction.label || transaction.name || transaction.constructor.name + }; + } + + fuzzError (error, step, operation, action, sourceBefore, sourceAfter) { + return new ReconciliationFuzzError( + `Reconciliation fuzzing failed: ${error.message}`, + { + seed: this.seed, + step, + operation, + action, + actions: [...this.actions, { step, operation, ...action }], + sourceBefore, + sourceAfter + }, + error + ); + } + + async step () { + const step = this.actions.length; + const sourceBefore = await this.subjectModule.source(); + let operation = 'selectOperation'; + let action = {}; try { - parse(editor.textString); - } catch (err) { - b.getWindow().remove(); - change.push(sourceBefore); - change.push(editor.textString); - return change; + ({ operation, action } = this.chooseAndPerformOperation()); + await this.component._changeTracker.onceChangesProcessed(); + const sourceAfter = await this.subjectModule.source(); + parse(sourceAfter); + const sourceLimit = this.initialSourceLength + this.maxSourceGrowthPerStep * (step + 1); + if (sourceAfter.length > sourceLimit) { + throw new Error(`Generated source grew to ${sourceAfter.length} characters (limit: ${sourceLimit})`); + } + if (this.validateSource) { + const changedStyleProperties = this.propertyHistory + .filter(change => + !['layout', 'vertices'].includes(change.property) && + this.isAttached(change.target) + ) + .map(change => ({ path: this.pathOf(change.target), property: change.property })); + const introducedStyleProperties = this.allMorphs() + .filter(morph => + this.isProjectionallyAdded(morph) || + this.isEmbeddedTextMorph(morph) + ) + .flatMap(morph => RECONCILIATION_FUZZ_STABLE_STYLE_PROPERTIES + .filter(property => morph.styleProperties.includes(property)) + .map(property => ({ path: this.pathOf(morph), property }))); + const styleProperties = [...changedStyleProperties, ...introducedStyleProperties]; + await this.validateSource(sourceAfter, { + seed: this.seed, + step, + operation, + action, + styleProperties, + component: this.component, + componentDescriptor: this.componentDescriptor + }); + } + const recordedAction = { + step, + operation, + ...action, + sourceLengthBefore: sourceBefore.length, + sourceLengthAfter: sourceAfter.length + }; + this.actions.push(recordedAction); + return recordedAction; + } catch (error) { + if (operation === 'selectOperation' && this.selectedOperation) { + operation = this.selectedOperation; + } + const projectionError = error.cause || error; + if (!projectionError.batch) { + projectionError.batch = this.component._changeTracker?.lastShadowCommandBatch; + } + let sourceAfter; + try { sourceAfter = await this.subjectModule.source(); } catch (sourceError) { sourceAfter = String(sourceError); } + throw this.fuzzError(error, step, operation, action, sourceBefore, sourceAfter); } - if (editor.textString.match(/part\((A|B|C)\)/)) debugger; } + + async run (steps = 100) { + if (!Number.isInteger(steps) || steps < 0) throw new Error(`Invalid reconciliation fuzz step count: ${steps}`); + this.initialSourceLength = (await this.subjectModule.source()).length; + while (this.actions.length < steps) await this.step(); + return { + seed: this.seed, + steps, + actions: this.actions.slice(), + source: await this.subjectModule.source() + }; + } +} + +async function resetModuleSource (targetModule, source) { + await targetModule.reset(); + if (targetModule.format() === 'global') { + await targetModule.changeSource('', { moduleId: targetModule.id }); + await targetModule.reload(); + await targetModule.setFormat('register'); + await targetModule.changeSource(source, { moduleId: targetModule.id }); + await targetModule.reload(); + } else { + await targetModule.changeSource(source, { moduleId: targetModule.id }); + } +} + +export async function createReconciliationFuzzer ({ + baseModuleId = defaultBaseModuleId, + subjectModuleId = defaultSubjectModuleId, + baseSource = reconciliationFuzzBaseSource, + subjectSource = reconciliationFuzzSubjectSource(baseModuleId), + resetSource = true, + ...options +} = {}) { + const baseModule = module(baseModuleId); + const subjectModule = module(subjectModuleId); + if (resetSource) { + await resetModuleSource(baseModule, baseSource); + await resetModuleSource(subjectModule, subjectSource); + } + + const { Base, Leaf, ModelPart } = await baseModule.load(); + const { Subject } = await subjectModule.load(); + for (const descriptor of [Base, Leaf, ModelPart, Subject]) descriptor.previouslyRemovedMorphs = new WeakMap(); + const component = await Subject.edit(); + + return new ReconciliationFuzzer({ + component, + componentDescriptor: Subject, + components: Object.assign([Base, Leaf, ModelPart], { + base: Base, + nested: Leaf, + model: ModelPart + }), + subjectModule, + ...options + }); +} + +/** + * Manual entry point for a workspace: + * result = await runReconciliationFuzz({ steps: 1000, seed: 'my-seed' }) + */ +export async function runReconciliationFuzz ({ steps = 100, ...options } = {}) { + const fuzzer = await createReconciliationFuzzer(options); + return fuzzer.run(steps); } diff --git a/lively.ide/components/editor.js b/lively.ide/components/editor.js index 2c9768f480..3ccabb4907 100644 --- a/lively.ide/components/editor.js +++ b/lively.ide/components/editor.js @@ -5,7 +5,7 @@ import module from 'lively.modules/src/module.js'; import { withAllViewModelsDo } from 'lively.morphic/components/policy.js'; import { ComponentChangeTracker } from './change-tracker.js'; import { findComponentDef, getComponentNode, scanForNamesInGenerator } from './helpers.js'; -import { replaceComponentDefinition, Reconciliation, createInitialComponentDefinition } from './reconciliation.js'; +import { replaceComponentDefinition, createInitialComponentDefinition } from './component-definition.js'; import { parse } from 'lively.ast'; import { once } from 'lively.bindings'; import { evalAsSpec } from 'lively.morphic/components/core.js'; @@ -78,8 +78,9 @@ export class InteractiveComponentDescriptor extends ComponentDescriptor { ensureNamesInSourceCode () { if (this._hasGeneratedNames) { this._hasGeneratedNames = false; - Reconciliation.ensureNamesInSourceCode(this); + return false; } + return true; } checkForGeneratedNames () { @@ -132,6 +133,7 @@ export class InteractiveComponentDescriptor extends ComponentDescriptor { const sceneGraph = c.world().sceneGraph; const pos = c.position; const prevOwner = c.owner; + c._changeTracker?.dispose(); c.remove(); if (sceneGraph) sceneGraph.refresh(); const updatedComponentMorph = prevOwner.addMorph(this.getComponentMorph()); @@ -181,6 +183,7 @@ export class InteractiveComponentDescriptor extends ComponentDescriptor { } stopEditSession () { + this._cachedComponent?._changeTracker?.dispose(); this._backupComponentDef = null; this._cachedComponent = null; } diff --git a/lively.ide/components/helpers.js b/lively.ide/components/helpers.js index 43027ef2cf..236b27d4ab 100644 --- a/lively.ide/components/helpers.js +++ b/lively.ide/components/helpers.js @@ -6,7 +6,6 @@ import { parse, query } from 'lively.ast'; import { module } from 'lively.modules/index.js'; export const DEFAULT_SKIPPED_ATTRIBUTES = ['metadata', 'styleClasses', 'isComponent', 'viewModel', 'activeMark', 'positionOnCanvas', 'selectionMode', 'acceptsDrops']; -export const COMPONENTS_CORE_MODULE = 'lively.morphic/components/core.js'; const exprSerializer = new ExpressionSerializer(); export async function getComponentDeclsFromScope (modId, scope) { @@ -106,6 +105,20 @@ export function getTextAttributesExpr (textMorph) { let { start, end } = getProp(rootPropNode, 'textAndAttributes').value; // eslint-disable-line no-use-before-define if (expr.__expr__[end - 1] === ',') end--; expr.__expr__ = expr.__expr__.slice(start - 1, end); + const referencedNames = new Set( + query.findGlobalVarRefs(`(${expr.__expr__})`).map(({ name }) => name) + ); + expr.bindings = Object.fromEntries(Object.entries(expr.bindings || {}) + .map(([moduleId, references]) => { + const filtered = (Array.isArray(references) ? references : [references]) + .filter(reference => referencedNames.has( + typeof reference === 'string' + ? reference + : reference?.local || reference?.exported + )); + return [moduleId, filtered]; + }) + .filter(([, references]) => references.length)); return expr; } @@ -128,7 +141,7 @@ export function getValueExpr (prop, value, depth = 0) { if (value && value.isPoint) value = value.roundTo(0.1); if (obj.isString(value) || obj.isBoolean(value)) value = JSON.stringify(value); if (prop === 'rotation') { - value = `num.toRadians(${num.toDegrees(value).toFixed(1)})`; + value = `num.toRadians(${num.toDegrees(value)})`; bindings['lively.lang'] = ['num']; } if (prop === 'blur') { @@ -142,12 +155,14 @@ export function getValueExpr (prop, value, depth = 0) { if (prop === 'master' && value) { valueAsExpr = value.getConfigAsExpression(); - if (valueAsExpr) valueAsExpr.__expr__ = indentExpression(valueAsExpr.__expr__, depth); + if (valueAsExpr && depth > 0) { + valueAsExpr.__expr__ = indentExpression(valueAsExpr.__expr__, depth); + } return valueAsExpr; } if (prop === 'layout' && value) { valueAsExpr = value.__serialize__(); - valueAsExpr.__expr__ = indentExpression(valueAsExpr.__expr__, depth); + if (depth > 0) valueAsExpr.__expr__ = indentExpression(valueAsExpr.__expr__, depth); return valueAsExpr; } if (value && !value.isMorph && value.__serialize__) { @@ -172,14 +187,6 @@ export function getValueExpr (prop, value, depth = 0) { return valueAsExpr; } -export function getFoldableValueExpr (prop, foldableValue, members, depth) { - const withoutValueGetter = obj.extract(foldableValue, members); - if (new Set(obj.values(withoutValueGetter)).size > 1) { - return getValueExpr(prop, withoutValueGetter, depth); - } - return getValueExpr(prop, foldableValue.valueOf()); -} - /****************** * NODE RETRIEVAL * ******************/ @@ -203,15 +210,6 @@ export function getProp (propsNode, prop) { return propNode; } -export function getParentRef (parsedComponent) { - const [parentNode] = query.queryNodes(parsedComponent, ` - // CallExpression [ - /:callee Identifier [ @name == 'component'] - ] - `); - if (parentNode.arguments.length > 1) return parentNode.arguments[0]; -} - /** * Returns the AST node of the component declarator inside the module; * @param { object } parsedContent - The AST of the module the component definition should be retrieved from. @@ -272,7 +270,7 @@ export function getPropertiesNode (parsedComponent, aMorphOrName) { /:properties "*" [ Property [ /:key Identifier [ @name == 'name' ] - && /:value Literal [ @value == '${name}'] + && /:value Literal [ @value == ${JSON.stringify(name)}] ] ] ] @@ -290,10 +288,10 @@ function getNodeFromSubmorphs (submorphsNode, morphName) { && /:arguments "*" [ ObjectExpression [ /:properties "*" [ - Property [ - /:key Identifier [ @name == 'name' ] - && /:value Literal [ @value == '${morphName}'] - ] + Property [ + /:key Identifier [ @name == 'name' ] + && /:value Literal [ @value == ${JSON.stringify(morphName)}] + ] ] ] ] @@ -302,7 +300,7 @@ function getNodeFromSubmorphs (submorphsNode, morphName) { /:properties "*" [ Property [ /:key Identifier [ @name == 'name' ] - && /:value Literal [ @value == '${morphName}'] + && /:value Literal [ @value == ${JSON.stringify(morphName)}] ] ] ] @@ -314,7 +312,7 @@ function getNodeFromSubmorphs (submorphsNode, morphName) { const [replaceRef] = query.queryNodes(submorphsNode, ` ./ CallExpression [ /:callee Identifier [ @name == 'replace' ] - && /:arguments "*" [ Literal [ @value == '${morphName}']] + && /:arguments "*" [ Literal [ @value == ${JSON.stringify(morphName)}]] ] `); if (replaceRef) return replaceRef; @@ -323,7 +321,7 @@ function getNodeFromSubmorphs (submorphsNode, morphName) { /:properties "*" [ Property [ /:key Identifier [ @name == 'name' ] - && /:value Literal [ @value == '${morphName}'] + && /:value Literal [ @value == ${JSON.stringify(morphName)}] ] ] ] @@ -367,32 +365,13 @@ export function getMorphNode (componentScope, aMorph) { return drillDownPath(getPropertiesNode(componentScope), path); } -export function getWithoutCall (submorphsNode, aMorph) { - const [withoutCall] = query.queryNodes(submorphsNode, ` - ./ CallExpression [ - /:callee Identifier [ @name == 'without' ] - && /:arguments "*" [ Literal [ @value == '${aMorph.name}'] ] - ] - `); - return withoutCall; -} - -export function getAddCallReferencing (submorphsNode, aMorph) { - const [addCall] = query.queryNodes(submorphsNode, ` - ./ CallExpression [ - /:callee Identifier [ @name == 'add' ] - && /:arguments "*" [ Literal [ @value == '${aMorph.name}'] ] - ] - `); - return addCall; -} - /************************ * SOURCE CODE PATCHING * ************************/ export function preserveFormatting (sourceCode, nodeToRemove) { if (!nodeToRemove) return nodeToRemove; + nodeToRemove = { ...nodeToRemove }; let commaRemoved = false; while (sourceCode[nodeToRemove.end].match(/\,/)) { @@ -409,91 +388,10 @@ export function preserveFormatting (sourceCode, nodeToRemove) { return nodeToRemove; } -export function applySourceChanges (sourceCode, changes) { - for (let change of changes) { - // apply the change to the module source - if (change.action === 'remove') { - change = preserveFormatting(sourceCode, change); - } - sourceCode = string.applyChange(sourceCode, change); - } - return sourceCode; -} - -export function applyChangesToTextMorph (aText, changes) { - for (let change of changes) { - switch (change.action) { - case 'insert': - aText.insertText(change.lines.join('\n'), aText.indexToPosition(change.start)); - break; - case 'remove': - change = preserveFormatting(aText.textString, change); - aText.replace({ - start: aText.indexToPosition(change.start), - end: aText.indexToPosition(change.end) - }, ''); - break; - case 'replace': - aText.replace({ - start: aText.indexToPosition(change.start), - end: aText.indexToPosition(change.end) - }, change.lines.join('\n')); - break; - } - } - return aText.textString; -} - export function scanForNamesInGenerator (closure) { return query.queryNodes(parse(`(${closure.toString()})`), ` // Property [ /:key Identifier [ @name == 'name' ]] `).map(hit => hit.value?.value); } -export function getAnonymousSpecs (parsedComponent) { - return query.queryNodes(parsedComponent, ` - // ObjectExpression [ - count(/ Property [ - /:key Identifier [ @name == 'name' ] - ]) == 0 - ]`); -} - -export function getAnonymousAddedParts (parsedComponent) { - return query.queryNodes(parsedComponent, ` - // CallExpression [ - /:callee Identifier [ @name == 'add' ] - && /:arguments "*" [ - CallExpression [ - /:callee Identifier [ @name == 'part' ] - && - count(/ ObjectExpression [ - /:properties "*" [ - Property [ - /:key Identifier [ @name == 'name' ] - ] - ] - ]) == 0 - - ] - ] - ] - `); -} - -export function getAnonymousParts (parsedComponent) { - return query.queryNodes(parsedComponent, ` - // CallExpression [ - /:callee Identifier [ @name == 'part' ] - && count(/ ObjectExpression [ - /:properties "*" [ - Property [ - /:key Identifier [ @name == 'name' ] - ] - ] - ]) == 0 - ] - `); -} - export { getNodeFromSubmorphs }; diff --git a/lively.ide/components/reconciliation-projectional-core-plan.md b/lively.ide/components/reconciliation-projectional-core-plan.md new file mode 100644 index 0000000000..da6378bbaf --- /dev/null +++ b/lively.ide/components/reconciliation-projectional-core-plan.md @@ -0,0 +1,625 @@ +# Morphic Change Engine and Command-Driven Projectional Reconciliation + +Status: implementation plan +Created: 2026-07-16 + +## Purpose + +Repair the morphic change and undo infrastructure, then replace the mutation-driven component reconciler incrementally with a command-driven projectional core built on that reliable foundation. The source remains the persisted representation, while an immutable semantic document becomes authoritative during a component editing transaction. + +The two efforts are intentionally connected but remain separate domains: + +- the morphic change engine applies, rolls back, observes, and journals reliable runtime mutations; +- the component command engine expresses author intent and updates the semantic component document; +- the runtime projector applies component documents through the morphic change engine. + +The current change manager, reconciler, undo manager, and fuzzers should serve as behavioral specifications during migration. Existing editing workflows must remain usable while operation and command families are moved to the new architecture one at a time. + +## Target architecture + +```text + Generic EditTransaction / Undo Journal + │ + ┌─────────────────────┴─────────────────────┐ + │ │ + Morphic change engine Component command engine + Operations and ChangeSets Commands and ComponentDocument + │ │ + Ordinary runtime mutations ┌───────────┴───────────┐ + │ │ + Source projector Runtime projector + │ │ + │ Morphic ChangeSet + └───────────┬───────────┘ + │ + Atomic edit transaction + ┌───────────┴───────────┐ + │ │ + JavaScript source Policies and morphs +``` + +The main boundaries are: + +- `MorphicOperation`: one explicit and reversible runtime mutation. +- `MorphicChangeSet`: immutable ordered group of runtime operations. +- `EditTransaction`: generic undoable user action spanning one or more domains. +- `ComponentDocument`: immutable semantic representation of a component definition. +- `ComponentCommand`: explicit component-authoring intent. +- `reduce(document, command)`: pure semantic state transition. +- `SourceAdapter`: parses source into a document and produces formatting-preserving edits. +- `RuntimeProjector`: derives a morphic change set from a component document. +- `TransactionCoordinator`: validates and atomically commits source, document, and runtime changes. + +The existing `stylePolicy.spec` becomes a runtime projection rather than a second mutable authority. + +## Part I: Repair the morphic change foundation + +### Phase 0: Define change semantics and invariants + +Define the following concepts before changing implementation: + +- `MorphicOperation`: one atomic runtime mutation with an exact inverse. +- `MorphicChangeSet`: an immutable, ordered group of operations. +- `EditTransaction`: a labeled and reversible user action containing morphic change sets or domain commands. +- `Projection`: mutations generated from authoritative state rather than new user intent. +- `Replay`: undo or redo execution that produces notifications without creating another history entry. + +Every morphic operation should expose a closed kind and an interface such as: + +```js +{ + kind, + targetId, + before, + after, + metadata, + apply(context), + invert() +} +``` + +Initial operation kinds: + +- `SetMorphProperty`; +- `InsertMorph`; +- `RemoveMorph`; +- `MoveMorph`; +- `ReplaceText`; +- `CustomOperation`. + +`CustomOperation` must require an explicit reversible handler. Arbitrary undo closures should be deprecated. + +### Phase 1: Add characterization tests + +Capture current behavior and known failures before replacing internals: + +- scalar property undo and redo; +- sibling reordering; +- reparenting between owners; +- removal and restoration at the same index; +- nested method changes; +- metadata propagation; +- synchronous exceptions inside `withMetaDo`; +- asynchronous callbacks; +- replay without new undo entries; +- mutable property values; +- listener notification order; +- transaction rollback after a failed operation. + +These tests define which behavior is intentional and which behavior must change. + +### Phase 2: Implement atomic morphic change sets + +Introduce an explicit transaction API behind the existing `ChangeManager` facade: + +```js +changeManager.transaction({ + label: 'move morph', + origin: 'direct-manipulation', + undoable: true +}, transaction => { + transaction.perform(operation); +}); +``` + +Required guarantees: + +1. Validate operations before application where possible. +2. Join nested operations into the active transaction. +3. Notify observers only after commit. +4. Reverse already-applied operations when an exception occurs. +5. Make committed change sets immutable. +6. Prevent replay from creating another history entry. +7. Include transaction ID, origin, and replay direction in notifications. + +Keep `setProperty`, `addMethodCallChangeDoing`, `withMetaDo`, `undoStart`, and `undoStop` as compatibility facades while callers migrate. + +### Phase 3: Fix property changes and metadata + +Replace `ValueChange` internally with `SetMorphProperty`. + +Required behavior: + +- capture the actual previous and resulting values; +- restore operation metadata during replay; +- distinguish value semantics from reference semantics; +- provide property-specific snapshot hooks for mutable values; +- prevent in-place mutations from being presented as reliably reversible property changes. + +Fix `withMetaDo` as an early, isolated correction: + +- remove the `return` from `finally` so synchronous errors propagate; +- define it as synchronous and reject promise-returning callbacks; +- perform prompts and other asynchronous preparation before opening a mutation transaction; +- use an explicit transaction handle rather than an ambient metadata stack for asynchronous workflows. + +This avoids unsafe metadata leakage or loss when asynchronous operations overlap. + +### Phase 4: Replace structural method records + +Refactor `addMorphAt` and `removeMorph` around one structural operation: + +```js +MoveMorph({ + morphId, + fromOwnerId, + fromIndex, + toOwnerId, + toIndex, + transformBefore, + transformAfter +}) +``` + +Define insertion as a move from no owner, removal as a move to no owner, sibling reordering as a move within one owner, and reparenting as a move between owners. Derive the inverse mechanically by swapping before and after state. + +Nested implementation details must not be emitted as separate user-level changes. A reparent must be observed as one committed `MoveMorph`, not an intermediate removal followed by an addition. + +### Phase 5: Generalize undo history + +Make `UndoManager` store generic edit transactions: + +```js +{ + label, + apply(), + reverseApply(), + canMergeWith(other), + merge(other) +} +``` + +It should support: + +- morphic change-set entries; +- component command entries; +- text transactions; +- grouped mixed-domain transactions. + +Undo and redo become transaction replay rather than new recording sessions. Existing grouping and debounce behavior can remain behind the generic interface. + +## Part II: Introduce component semantics and projections + +### Phase 6: Add origin-aware routing and the legacy bridge + +Every committed transaction must identify its origin, for example: + +```js +'user' +'component-command' +'runtime-projection' +'source-projection' +'undo' +'redo' +'layout' +'animation' +``` + +Runtime projection should use metadata such as: + +```js +{ + origin: 'runtime-projection', + reconcileChanges: false +} +``` + +This prevents projection feedback loops while allowing rendering and ordinary morph observers to react. + +Change component tracking to consume committed change sets rather than every nested `onChange` and `onSubmorphChange` notification. During migration, add a legacy adapter: + +```text +Committed MorphicChangeSet -> Legacy component adapter -> ComponentCommand +``` + +Initial mappings: + +- `SetMorphProperty` to `SetProperty` or `RenameNode`; +- insertion to `IntroduceNode`; +- removal to `RemoveNode` or `SuppressInheritedNode`; +- structural movement to `MoveNode`; +- restoration to `RestoreInheritedNode`. + +The adapter may consult component provenance to disambiguate inherited operations. It is a compatibility boundary, not the final entry point: component-aware tools should ultimately issue component commands directly. + +### Phase 7: Define the semantic component model + +Create a normalized, runtime-independent document model. It must not contain morph references, `PolicyApplicator` instances, parent pointers, or reconciliation flags. + +Conceptually: + +```js +ComponentDocument { + revision, + componentId, + moduleId, + exportName, + parentComponent, + root, + sourceMetadata +} + +ComponentNode { + id, + name, + origin, + typeExpression, + properties, + children +} +``` + +Each effective child needs explicit provenance: + +- locally defined; +- added to a derived component; +- inherited from another definition; +- inherited but suppressed; +- inherited with local overrides; +- inherited with a local ordering constraint. + +Property entries should distinguish: + +- no local override; +- explicit local value; +- explicit opaque source expression. + +This avoids using property absence, runtime equality, or `_originalSpec` as indirect evidence of author intent. + +#### Foundational decision: identity + +Names must stop being the in-memory identity. They can remain source-level selectors for compatibility, but commands and reducers should use stable node IDs. + +Before structural operations are implemented, decide how identity persists: + +- Initially, IDs may be stable for the lifetime of a parsed document and preserved through reducer operations. +- Investigate persistent IDs for reliable identity across reparses, rename, and reparent operations. +- If persistent IDs would pollute component syntax, maintain a source-node identity map and define exactly when identity may reset. + +### Phase 8: Introduce the component command protocol + +Add validated command factories or classes: + +```js +SetProperty({ nodeId, property, value }) +ClearPropertyOverride({ nodeId, property }) +RenameNode({ nodeId, name }) +IntroduceNode({ parentId, node, beforeId }) +MoveNode({ nodeId, parentId, beforeId }) +RemoveNode({ nodeId }) +SuppressInheritedNode({ nodeId }) +RestoreInheritedNode({ nodeId, parentId, beforeId }) +SetMaster({ nodeId, masterExpression }) +EditText({ nodeId, operation }) +``` + +Every command should contain: + +- the target component and expected document revision; +- stable node IDs; +- semantic values or opaque expressions; +- enough information to construct an inverse command; +- optional preconditions such as the expected parent or previous value. + +Direct-manipulation tools should emit commands before mutating the morph tree. For legacy callers that currently mutate first, add a temporary adapter that converts the existing change notification into a command. + +### Phase 9: Build the pure component reducer + +Implement a reducer with an interface such as: + +```js +const result = reduceComponent(document, command); +``` + +The result should contain: + +```js +{ + document, + inverseCommand, + semanticDelta, + diagnostics +} +``` + +Reducer invariants include: + +- IDs are unique. +- Sibling names satisfy component naming rules. +- A node has exactly one parent. +- Ordering references point to existing siblings. +- An inherited node is either present or suppressed. +- Overrides only target known nodes and properties. +- Local and inherited provenance cannot be partially combined. +- Cyclic reparenting is impossible. + +The reducer must not: + +- access live morphs; +- parse or modify JavaScript; +- mutate policies; +- update editors; +- load modules. + +The bulk of reconciliation semantics should live at this layer. + +### Phase 10: Parse source into the document model + +Create a source adapter around the existing AST helpers in `lively.ide/components/helpers.js`. + +It should understand: + +- ordinary morph specifications; +- `part(...)`; +- `add(...)`; +- `without(...)`; +- nested overrides; +- ordering anchors; +- component inheritance; +- aliased imports; +- master policies; +- opaque property expressions. + +Keep source-node metadata separate from semantics: + +```js +SourceMetadata { + nodeIdToAstLocation, + propertyLocations, + formattingHints, + importBindings, + originalExpressions +} +``` + +Parsing should produce diagnostics for dynamic structures it cannot safely represent. During migration, those definitions can remain on the legacy reconciler. + +Required round-trip invariant: + +```text +parse(source) -> document -> projectSource(document) -> parse(result) +``` + +The two parsed documents must be semantically equivalent. + +### Phase 11: Add the source projector + +The source projector compares the previous and next documents and emits structural source edits. It must not know about morph change events. + +Responsibilities: + +- preserve unchanged expressions and formatting; +- create or remove `add`, `part`, and `without` constructs; +- update ordering selectors; +- manage imports and aliases; +- update descendant definitions affected by rename; +- validate all generated modules by reparsing them. + +During the transition, proven patch helpers were reused from the old reconciler and moved behind semantic operations: + +```js +sourceProjector.renameNode(before, after) +sourceProjector.moveNode(before, after) +``` + +The projector must never infer whether a node is inherited. That information comes from the document. + +### Phase 12: Add the runtime projector + +Convert a `ComponentDocument` into a prepared `MorphicChangeSet` that updates: + +- style policies; +- cached component instances; +- derived active edit sessions. + +Apply the change set through the repaired morphic change engine with `origin: 'runtime-projection'`. Start with a coarse projection, such as reapplying a policy or recreating the affected subtree, and optimize incrementally. Correctness takes priority over preserving targeted runtime mutations from the legacy reconciler. + +The runtime projector may own compatibility markers temporarily, but the reducer must not depend on: + +- `__wasAddedToDerived__`; +- `previouslyRemovedMorphs`; +- cached source expressions; +- removed-morph history. + +Component undo should use inverse commands or document snapshots. The corresponding morphic change set is an application and rollback mechanism, not the authoritative component undo representation. + +### Phase 13: Add cross-domain transaction coordination + +A component command transaction should: + +1. Verify the expected document revision. +2. Reduce the command into a candidate document. +3. Resolve affected derived documents. +4. Generate all source plans. +5. Parse and validate every resulting module. +6. Prepare the runtime projection as a morphic change set. +7. Commit source, document revisions, and the morphic change set as one edit transaction. +8. Roll everything back if any commit step fails. + +If source planning fails, the live morph must not change. If runtime projection fails, source, documents, and runtime state must all be restored. This extends the existing source-only transaction planning around `planReconciliationChanges` to cover semantic documents and runtime state. + +### Phase 14: Migrate component vertical slices + +Migrate behavior by command family: + +1. Scalar property set and clear. +2. Text changes. +3. Master changes. +4. Rename. +5. Local node introduction and removal. +6. Reordering. +7. Reparenting. +8. Inherited suppression and restoration. +9. Nested parts and structural overrides. +10. Propagation across derived components and modules. + +For each family: + +1. Translate existing events into commands. +2. Run the new planner in shadow mode. +3. Compare its projected result with the legacy result. +4. Cut over behind a feature flag. +5. Retain fallback for unsupported syntax. +6. Remove the corresponding legacy class after sustained test coverage. + +Do not migrate structural operations until document identity and provenance are reliable and the morphic `MoveMorph` operation has passed its foundation gate. + +### Phase 15: Add layered model-based fuzzing + +Use two related fuzzers. + +#### Morphic change-engine fuzzer + +Generate property changes, insertions, removals, reorderings, reparentings, nested transactions, replay, and failures during application. + +Check that: + +- applying an operation followed by its inverse restores the exact tree; +- owner, child-index, and identity invariants hold; +- replay does not grow history; +- failed transactions restore state; +- notification ordering is deterministic; +- metadata and origin survive replay. + +#### Component projection fuzzer + +Evolve the current reconciliation fuzzer to generate semantic component commands rather than raw morph mutations. + +After every step, compare: + +- the reducer document; +- projected source parsed back into a document; +- the runtime policy; +- the instantiated morph tree. + +Add metamorphic properties: + +- a command followed by its inverse restores the original document; +- parsing after source projection is semantically equivalent; +- projection is idempotent; +- equivalent command sequences converge; +- failed commands leave all state unchanged; +- source and runtime projections agree after every step. + +Also compare the runtime projector's morphic change-set inverse with the semantic inverse command. Keep the existing mutation-driven reconciliation fuzzer temporarily as an end-to-end compatibility test for the legacy adapter. + +## Suggested code organization + +```text +lively.morphic/changes/ + operations.js + change-set.js + transaction.js + manager.js + +lively.morphic/changes.js # compatibility facade during migration +lively.morphic/undo.js # generic EditTransaction journal + +lively.ide/components/reconciliation/ + commands.js + component-document.js + reducer.js + invariants.js + source-adapter.js + source-projector.js + runtime-projector.js + transaction.js + morphic-change-set-adapter.js +``` + +`lively.morphic/changes.js` remains the generic Morphic event facade. The legacy +`lively.ide/components/reconciliation.js` reconciler was deleted after the +projectional cutover; unrelated component creation and removal commands now live +in `lively.ide/components/component-definition.js`. + +## Initial implementation sequence + +### Pull request 1: Change-manager characterization + +- Add tests for property replay, metadata, error propagation, reorder, reparent, remove, nested changes, and listener ordering. +- Mark known-broken expectations explicitly. +- Do not change production behavior except to expose deterministic diagnostics where required by the tests. + +### Pull request 2: Morphic transaction kernel + +- Add immutable operations, morphic change sets, transaction IDs, origins, rollback, and replay mode behind the existing facade. +- Fix synchronous exception propagation and define synchronous metadata scope behavior. +- Move scalar properties onto `SetMorphProperty`. + +### Pull request 3: Structural operations and undo journal + +- Add exact `MoveMorph` semantics for insertion, removal, reorder, and reparent. +- Move `UndoManager` to generic edit transactions. +- Retain compatibility methods for existing callers. + +### Pull request 4: Component transaction bridge + +- Change component observation to consume committed change sets. +- Add origin-aware feedback suppression. +- Translate morphic operations into component commands in shadow mode. + +### Pull request 5: Component architecture scaffolding + +- Add component commands, `ComponentDocument`, validation, and the pure reducer interface. +- Parse simple component and property structures. +- Do not change production component reconciliation behavior. + +### Pull request 6: Shadow-mode property projection + +- Translate scalar property transactions into `SetProperty` and `ClearPropertyOverride` commands. +- Reduce against the parsed document. +- Generate projected source and a runtime morphic change set without applying them. +- Compare projected semantics with legacy output. + +### Pull request 7: Property cutover + +- Apply source and runtime projections for scalar property commands as one edit transaction. +- Keep a feature-flagged legacy fallback. +- Add inverse-command undo and cross-domain rollback tests. + +### Subsequent pull requests + +- Move command families through shadow mode and cutover in the order listed in Phase 14. +- Treat identity, rename, and structural movement as explicit milestone reviews rather than routine extensions. + +## Completion criteria + +The migration is complete when: + +- committed morphic change sets are immutable, reversible, and atomic; +- reorder, reparent, insertion, and removal use exact structural operations; +- undo and redo replay transactions without creating new history; +- metadata, origin, errors, and rollback behave deterministically; +- component tracking consumes committed change sets rather than nested mutation effects; +- every supported direct manipulation starts as a semantic command; +- the reducer contains no runtime or source-editing dependencies; +- source and runtime are produced from the same component document; +- runtime projections are applied through morphic change sets; +- rename, move, remove, and restore do not depend on mutation history; +- `__wasAddedToDerived__` and removed-expression caches are unnecessary for correctness; +- both model-based fuzzers can run long sequences without divergence; +- source edits and direct manipulation can alternate without losing identity or overrides; +- legacy `MethodCallChange` inverses are no longer used for component correctness; +- the legacy reconciliation subclasses can be deleted. + +The first practical milestone is the morphic foundation gate: scalar property changes, reorder, reparent, remove, undo, and redo all work deterministically through the new transaction engine. The first component milestone follows with `ComponentDocument`, the command protocol, and a shadow-mode `SetProperty` vertical slice. diff --git a/lively.ide/components/reconciliation.js b/lively.ide/components/reconciliation.js deleted file mode 100644 index 4699590575..0000000000 --- a/lively.ide/components/reconciliation.js +++ /dev/null @@ -1,1633 +0,0 @@ -import { arr, tree, obj, string } from 'lively.lang'; -import { - getNodeFromSubmorphs, - getAnonymousAddedParts, - getAnonymousParts, - getAnonymousSpecs, - getParentRef, - getComponentDeclsFromScope, - getAddCallReferencing, - getWithoutCall, - getEligibleSourceEditorsFor, - applySourceChanges, - getPathFromMorphToMaster, - getTextAttributesExpr, - getValueExpr, - getFoldableValueExpr, - standardValueTransform, - COMPONENTS_CORE_MODULE, - getMorphNode, - getPropertiesNode, - getProp, - DEFAULT_SKIPPED_ATTRIBUTES, - convertToExpression, - findComponentDef, - applyChangesToTextMorph -} from './helpers.js'; -import { undeclaredVariables } from '../js/import-helper.js'; -import { ImportInjector, ImportRemover } from 'lively.modules/src/import-modification.js'; -import module from 'lively.modules/src/module.js'; -import { parse, stringify, nodes, query } from 'lively.ast'; -import { notYetImplemented } from 'lively.lang/function.js'; -import { isFoldableProp, getDefaultValueFor } from 'lively.morphic/helpers.js'; -import { resource } from 'lively.resources'; -import { ExpressionSerializer } from 'lively.serializer2'; -import { PolicyApplicator } from 'lively.morphic/components/policy.js'; -import { Range } from 'lively.morphic'; - -export const exprSerializer = new ExpressionSerializer(); - -function isWithinDerivedComponent (aMorph, includeSelf) { - // not entirely correct. This will incorrectly return true - // if there is just an inherited inline policy present - if (includeSelf && aMorph.master?.parent) return true; - if (aMorph.__wasAddedToDerived__) return false; - for (const each of aMorph.ownerChain()) { - if (each.master?.parent) return true; - if (each.__wasAddedToDerived__) return false; - } - return false; -} - -/** - * The cheap way is just to generate a new spec from a component morph. - * however: - * 1. this is most inefficient solution since it involves generating and stringifying a AST. (slow) - * 2. it does not preserve the original formatting of the user. - * - * instead we want to rather patch the source as needed to reconcile changes - * that happen in direct manipulation. This function should only be used - * in cases we do NOT have a preexisting definition residing in source.f - * @param { Morph } aComponent - The component morph we use to create the component definition from. - * @param { boolean } asExprObject - Wether or not to return an expression object (with binding info) instead of just a string. - * @returns { string|object } The component definition as stringified expression or expression object. - */ -export function createInitialComponentDefinition (aComponent, asExprObject = false) { - let { __expr__, bindings } = convertToExpression(aComponent, { - skipAttributes: [...DEFAULT_SKIPPED_ATTRIBUTES, 'treeData'] - }); - __expr__ = 'component(' + __expr__ + ')'; // remove name attr - - if (asExprObject) { - if (bindings['lively.morphic']) { - arr.pushIfNotIncluded(bindings['lively.morphic'], 'component'); - } else { - bindings['lively.morphic'] = ['component']; - } - return { - __expr__, bindings - }; - } - - return __expr__; -} - -export function insertMorphChange (submorphsArrayNode, addedMorphExpr, nextSibling = false) { - let insertPos = arr.last(submorphsArrayNode.elements).end; - const action = { action: 'insert', start: insertPos, lines: [',' + addedMorphExpr] }; - if (nextSibling) { - const siblingNode = getNodeFromSubmorphs(submorphsArrayNode, nextSibling.name); - if (!siblingNode) return action; - action.start = siblingNode.start; - action.lines = [addedMorphExpr + ',']; - } - return action; -} - -/** - * Given a morph with a corresponding spec, determine wether it still - * includes enough properties to bepreserved. If there is no property(s) - * exceeding the set of ignored props, the spec is determined removable - * and we escalate the consideration of removal further to the parent. - * By doing this, we are able to cleanup unnessecary specs that clutter - * component definitions. - * @param { object } nodeToRemove - The node of the sopec we initially consider to remove. - * @param { object } parsedComponent - The node pointing to the entire component definition. - * @param { Morph } fromMorph - The morph that we traverse the owner chain from in case of escalation. - * @param { string[] } [ignoredProps= ['name', 'submorphs']] - The set of property names that are not considered enough for the node to be preserved. - * @returns { object } Returns the final node deemed to be removed. - */ - -// FIXME: add toMorph param in order to flexibily stop and support inline policies? - -function determineNodeToRemoveSubmorphs (nodeToRemove, parsedComponent, fromMorph, ignoredProps = ['name', 'submorphs']) { - let curr = fromMorph; - let propNode = getPropertiesNode(parsedComponent, curr); - const ignoreQuery = ` - / Property [ - /:key Identifier [ ${ignoredProps.map(prop => `@name != '${prop}'`).join(' && ')} ] - ]`; - let submorphsNode = getProp(propNode, 'submorphs'); - while ( - query.queryNodes(propNode, ignoreQuery).length === 0 && - (submorphsNode?.value.elements.length || 0) < 2 - ) { - // if we are wrapped by a part call we should use the submorphs node instead - if (!curr.owner) break; - const withinDerived = isWithinDerivedComponent(curr); - nodeToRemove = withinDerived ? propNode : submorphsNode; - if (withinDerived && query.queryNodes(propNode, ignoreQuery).length === 0) nodeToRemove = propNode; - curr = curr.owner; - propNode = getPropertiesNode(parsedComponent, curr?.isComponent ? null : curr); - submorphsNode = getProp(propNode, 'submorphs'); - if (submorphsNode?.value.elements.length < 2) nodeToRemove = submorphsNode; - if (curr.isWorld) break; - } - // ensure formatting is preserved - return nodeToRemove; -} - -/** - * Inserts a new property into a properties node of a component definition - * located in a source string. - * @param { string } sourceCode - The source code to adjust. - * @param { object } propertiesNode - The AST node pointing to the properties object to adjust. - * @param { string } key - The property name. - * @param { object } valueExpr - The expression object of the value of the property. - * @param { Text } [sourceEditor = false] - An optional source code editor that serves as the store of the source code. - * @returns { string } The transformed source code. - */ -export function insertPropChange (sourceCode, propertiesNode, key, valueExpr) { - const nameProp = propertiesNode.properties.findIndex(prop => prop.key.name === 'name'); - const typeProp = propertiesNode.properties.findIndex(prop => prop.key.name === 'type'); - const submorphsProp = propertiesNode.properties.findIndex(prop => prop.key.name === 'submorphs'); - const modelProp = propertiesNode.properties.findIndex(prop => prop.key.name?.match(/viewModelClass|defaultViewModel/)); - const isVeryFirst = propertiesNode.properties.length === 0; - let afterPropNode = propertiesNode.properties[Math.max(typeProp, nameProp, modelProp)]; - let keyValueExpr = '\n' + key + ': ' + valueExpr; - let insertationPoint; - if (!afterPropNode || key === 'submorphs') { - if (isVeryFirst) insertationPoint = propertiesNode.start + 1; - else afterPropNode = arr.last(propertiesNode.properties); - } - if (submorphsProp > -1) { - // ensure that we are inserted before - const ia = afterPropNode ? propertiesNode.properties.indexOf(afterPropNode) : 0; - afterPropNode = propertiesNode.properties[Math.min(ia, submorphsProp - 1)]; - if (!afterPropNode) { - insertationPoint = propertiesNode.start + 1; - keyValueExpr = keyValueExpr + ','; // but still need to ensure the comma - } - } - if (afterPropNode) { - keyValueExpr = ',' + keyValueExpr; - } - if (afterPropNode && !insertationPoint) { - insertationPoint = afterPropNode.end; - } - - // in this is the very first property we insert at all, - // we need to make sure no superflous newlines are kept around... - let changes = []; - if (isVeryFirst) { - keyValueExpr = `{${keyValueExpr}\n}`; - changes = [ - { action: 'replace', ...propertiesNode, lines: [keyValueExpr] } - ]; - } else { - changes = [ - { action: 'insert', start: insertationPoint, lines: [keyValueExpr] } - ]; - } - - return changes; -} - -export function deleteProp (sourceCode, parsedComponent, morphDef, propName, target, eraseIfEmpty) { - const propNode = getProp(morphDef, propName); - if (!propNode) { - return { needsLinting: false, changes: [] }; - } - if (eraseIfEmpty && morphDef.properties.length < 3) { - // since we are derived and only have the name prop left, - // we are eligible for removal - // since it is derived we only care about removing this morph entirely - const nodeToRemove = determineNodeToRemoveSubmorphs(morphDef, parsedComponent, target, [ - 'name', - 'submorphs', - propName - ]); - return { - needsLinting: true, - changes: [{ action: 'remove', ...nodeToRemove }] - }; - } - - const patchPos = propNode; - while (sourceCode[patchPos.end].match(/,| |\n/)) patchPos.end++; - return { - needsLinting: true, - changes: [{ action: 'remove', ...patchPos }] - }; -} - -/** - * Transforms a given source code string such that undefined required bindings are - * resolved by imports. - * @param { string } sourceCode - The source code to adjust the imports for. - * @param { object[] } requiredBindings - A list of required bindings for the source code. - * @param { Module } mod - The module the source code belongs to. - * @returns { string } The updated source code. - */ -export function fixUndeclaredVars (sourceCode, requiredBindings, mod) { - const S = mod.System; - const knownGlobals = mod.dontTransform; - const undeclared = undeclaredVariables(sourceCode, knownGlobals).map(n => n.name); - let updatedSource = sourceCode; - const changes = []; - if (undeclared.length === 0) return { updatedSource: sourceCode, changes }; - for (let [importedModuleId, exportedIds] of requiredBindings) { - for (let exportedId of exportedIds) { - // check if binding already present and continue if that is the case - if (!undeclared.includes(exportedId)) continue; - arr.remove(undeclared, exportedId); - // any way to avoid the string modification? - let generated, from; - ({ generated, from, newSource: updatedSource } = ImportInjector.run(System, mod.id, mod.package(), updatedSource, { - exported: exportedId, - moduleId: module(S, importedModuleId).id, - pathInPackage: module(S, importedModuleId).pathInPackage(), - packageName: module(S, importedModuleId).package()?.name - })); - changes.push({ action: 'insert', start: from, lines: [generated] }); - } - } - return { updatedSource, changes }; -} - -/***************** - * MODULE UPDATE * - *****************/ - -/** - * Removes a component definition together with its export(s) from a module. - * This function is only used in response to removing a component definition from a package - * and therefore does not need to be decoupled from the module + source changes it performs. - * @param { string } entityName - The name of the component definition to remove. - * @param { string } modId - The name of the module to remove the component definition from. - */ -export async function removeComponentDefinition (entityName, mod) { - await mod.changeSourceAction(oldSource => { - const parsed = parse(oldSource); - const exportSpecs = query.queryNodes( - parsed, - `// ExportSpecifier [ - /:local Identifier [@name == "${entityName}"] - ], - // ExportDefaultDeclaration [ - /:declaration Identifier [@name == "${entityName}"] - ] - `); - let rangesToRemove = []; - for (let exportSpec of exportSpecs) { - while (oldSource[exportSpec.start - 1].match(/ /)) exportSpec.start--; - while (oldSource[exportSpec.end].match(/\,|\n/)) exportSpec.end++; - rangesToRemove.push({ action: 'remove', ...exportSpec }); - } - const componentDef = findComponentDef(parsed, entityName); - while (oldSource[componentDef.end].match(/\,|\n/)) componentDef.end++; - rangesToRemove.push({ action: 'remove', ...componentDef }); - - return ImportRemover.removeUnusedImports( - string.applyChanges(oldSource, arr.sortBy(rangesToRemove, range => -range.start)) - ).source; - }); -} - -/** - * Replaces a component definition within a module. - * This function is only used in response to resetting a component definition - * and therefore does not need to be decoupled from the module + source changes it performs. - * @param { string } defAsCode - The code snippet of the updated component definition. - * @param { string } entityName - The name of the const referencing the component definition. - * @param { string } modId - The id of the module to be updated. - */ -export async function replaceComponentDefinition (defAsCode, entityName, mod) { - await mod.changeSourceAction(oldSource => { - const { start, end } = findComponentDef(parse(oldSource), entityName); - return ImportRemover.removeUnusedImports(string.applyChanges(oldSource, [ - { start, end, action: 'replace', lines: [defAsCode] } - ])).source; - }); -} - -/** - * Inserts a new component definition into a module based on a morph that - * will be used to generate the definition. - * This function is only used for initial creation of new components and therefore - * does not need to be decoupled from the module creation + source code changes it performs. - * @param { Morph } protoMorph - The morph to be used to generate a component definition from. - * @param { string } variableName - The name of the variable that should reference the component definition. - * @param { string } modId - The id of the module to be changed. - */ -export async function insertComponentDefinition (protoMorph, entityName, mod) { - const scope = await mod.scope(); - await mod.changeSourceAction(oldSource => { - // insert the initial component definition into the back end of the module - const { __expr__: compCall, bindings: requiredBindings } = createInitialComponentDefinition(protoMorph, true); - const decl = `\n\const ${entityName} = ${compCall};\n\n`; - - // if there is a bulk export, insert the export into that batch, and also do not put - // the declaration after these bulk exports. - const finalExports = arr.last(scope.exportDecls); - if (!finalExports) { - return fixUndeclaredVars(oldSource + decl, Object.entries(requiredBindings), mod).updatedSource + - `\n\nexport { ${entityName} }`; - } - // insert before the exports - const updatedExports = { - ...finalExports, - specifiers: [...finalExports.specifiers, nodes.id(entityName)] - }; - - return System.lint(fixUndeclaredVars( - string.applyChanges(oldSource, [ - { action: 'replace', ...finalExports, lines: [decl, stringify(updatedExports)] } - ]), - Object.entries(requiredBindings), - mod).updatedSource)[0]; - }); -} - -export function canBeRenamed (mod, oldName, newName) { - // if (oldName === newName) return false; - if (string.camelCaseString(newName) in mod.recorder) return false; - return true; -} - -/** - * Given a proto morph, rename the corresponding component definition - * inside of the module it is defined in. In case the component is the - * top level component that determines the module's name, then we perform - * a renaming of the module. - * @param {type} protoMorph - description - */ - -export async function renameComponent (protoMorph, newName, system) { - const meta = protoMorph[Symbol.for('lively-module-meta')]; - if (!meta?.moduleId || !meta?.exportedName) return; - let mod = module(system, meta.moduleId); - const exports = await mod.exports(); - const oldName = meta.exportedName; - const parsedModule = await mod.ast(); - const descr = mod.recorder[oldName]; - const moduleNeedsRename = !descr.stylePolicy.parent; // works only for auto generated component files and this if fine - const { declarations: [{ id: decl }] } = findComponentDef(parsedModule, meta.exportedName); - const references = arr.compact((await getComponentDeclsFromScope(mod.id, await mod.scope())).map(ref => { - return getParentRef(ref[1]); - })); - const { local: exportedEntity } = exports.find(exp => exp.local === oldName)?.node || {}; - - let newModuleName; let oldModuleName = mod.shortName(); - if (moduleNeedsRename) { - newModuleName = string.decamelize(newName).split(' ').join('-') + '.cp.js'; - const newId = resource(mod.id).parent().join(newModuleName).url; - mod = await mod.renameTo(newId, { - unload: true, - removeFile: true, - updateDependants: true // implement this one - }); - } - await mod.ensureRecord(); - await mod.changeSourceAction(oldSource => { - // also replace the export, if exported separately - if (exportedEntity) { - oldSource = string.applyChange(oldSource, { - action: 'replace', ...exportedEntity, lines: [newName] - }); - } - // this will brick the module temporarily, which is no good! - const changes = arr.sortBy([ - { action: 'replace', ...decl, lines: [newName] }, - ...references.map(ref => ({ - action: 'replace', ...ref, lines: [newName] - })) - ], action => -action.start); - - return string.applyChanges(oldSource, changes); - }); - - // proceed and rename all of the derived ones - await mod.recorder[meta.exportedName].withDerivedComponentsDo(async descr => { - const meta = descr[Symbol.for('lively-module-meta')]; - if (meta.exportedName && meta.moduleId !== oldModuleName) { - const mod = descr.targetModule; - const parsedModule = await mod.ast(); - const imports = await mod.imports(); - const { declarations: [{ init: { arguments: [ref] } }] } = findComponentDef(parsedModule, meta.exportedName); - const importedEntity = imports.find(imp => imp.imported === oldName)?.node || {}; - await mod.changeSourceAction(oldSource => { - oldSource = string.applyChange(oldSource, { action: 'replace', ...ref, lines: [newName] }); - if (importedEntity) { - if (moduleNeedsRename) { - const { source } = importedEntity; - oldSource = string.applyChange(oldSource, { - action: 'replace', - ...source, - lines: [`'${source.value.split('/').slice(0, -1).concat(newModuleName).join('/')}'`] - }); - } - const imp = importedEntity.specifiers.find(spec => spec.imported.name === oldName); - oldSource = string.applyChange(oldSource, { action: 'replace', ...imp, lines: [newName] }); - } - return oldSource; - }); - } - }); - - return await mod.recorder[newName].edit(); -} - -export function insertMorphExpression (parsedComponent, sourceCode, newOwner, addedMorphExpr, nextSibling = false) { - const morphNode = getMorphNode(parsedComponent, newOwner); - const propsNode = morphNode && getPropertiesNode(morphNode); - const submorphsArrayNode = propsNode && getProp(propsNode, 'submorphs')?.value; - - if (!submorphsArrayNode) { - if (!propsNode) { - // uncollapse till morph expression: - // inserts a submorph drill down up to the submorphs: [*expression*] is inserted (insert action) - return uncollapseSubmorphHierarchy( // eslint-disable-line no-use-before-define - sourceCode, - parsedComponent, - newOwner, - addedMorphExpr - ); - } - // just generate an insert action that places the prop in the morph def - return { - needsLinting: true, // really? - bindings: addedMorphExpr.bindings, - changes: insertPropChange( - sourceCode, - propsNode, - 'submorphs', - `[${addedMorphExpr.__expr__}]` - ) - }; - } else { - // just generates an insert action that places the morph in the submorph array - return { - needsLinting: true, // obviously - bindings: addedMorphExpr.bindings, - changes: [insertMorphChange(submorphsArrayNode, addedMorphExpr.__expr__, nextSibling)] - }; - } -} - -/** - * In case the change of a morph needs to be reconciled, - * but said morph does not appear inside the component def, - * that means it was not yet mentioned since no overriding changes - * where applied. In this case we need to uncollapse the morph - * structure such that the overridden change can be reconciled - * accordingly. - * @param { string } sourceCode - The source code of the module affected. - * @param { object } parsedComponent - The AST of the component definition affected. - * @param { Morph } hiddenMorph - The morph with the change we need to uncover in the component definition. - * @returns { string } The transformed source code. - */ -export function uncollapseSubmorphHierarchy (sourceCode, parsedComponent, hiddenMorph, hiddenSubmorphExpr = false) { - let nextVisibleParent = hiddenMorph; - const idx = hiddenMorph.owner.submorphs.indexOf(hiddenMorph); - const nextSibling = idx !== -1 && hiddenMorph.owner.submorphs[idx + 1]; - const ownerChain = [hiddenMorph]; - let propertiesNode, morphToExpand; - do { - morphToExpand = nextVisibleParent; - nextVisibleParent = nextVisibleParent.owner; - ownerChain.push(nextVisibleParent); - propertiesNode = getPropertiesNode(parsedComponent, nextVisibleParent); - } while (!propertiesNode); - - const masterInScope = arr.findAndGet(morphToExpand.ownerChain(), m => m.master); - const uncollapsedHierarchyExpr = convertToExpression(morphToExpand, { - onlyInclude: ownerChain, - exposeMasterRefs: false, - uncollapseHierarchy: true, - masterInScope, // ensures no props are listed that are not overridden - skipAttributes: [...DEFAULT_SKIPPED_ATTRIBUTES, 'master', 'type'], - valueTransform: (key, val, aMorph) => { - if (hiddenSubmorphExpr && aMorph === hiddenMorph && key === 'submorphs') { - return [hiddenSubmorphExpr]; - } - return standardValueTransform(key, val, aMorph); - } - }); - // also support this expression to be customized - if (!uncollapsedHierarchyExpr) return { changes: [], needsLinting: false, bindings: [] }; - return insertMorphExpression(parsedComponent, sourceCode, nextVisibleParent, uncollapsedHierarchyExpr, nextSibling); -} - -export function applyModuleChanges (reconciliation, scope, system, sourceEditor = false) { - // order each group by module - // apply bulk to each module - let { changesByModule, modulesToLint, requiredBindingsByModule } = reconciliation; - const focusedModuleId = sourceEditor?.editorPlugin?.evalEnvironment.targetModule; - changesByModule = arr.groupBy(changesByModule, arr.first); - for (let moduleName in changesByModule) { - const mod = module(system, moduleName); - let { _source: sourceCode, id } = mod; - if (!sourceCode) continue; - const requiredBindingsForChanges = requiredBindingsByModule.get(id); - const runLint = modulesToLint.has(mod.fullName()); - const patchTextMorph = id === focusedModuleId; - if (patchTextMorph && !runLint) sourceCode = sourceEditor.textString; - let changes = changesByModule[moduleName].map(l => l[1]).flat(); - changes = arr.sortBy(changes, change => change.start).reverse(); - let updatedSource = patchTextMorph && !runLint - ? applyChangesToTextMorph(sourceEditor, changes) - : applySourceChanges(sourceCode, changes); - - let hasUndefinedVariables = false; - const importedRefs = new Set(scope.importSpecifiers.map(spec => spec.name)); - for (let [_, refs] of requiredBindingsForChanges) { - if (!refs.every(ref => importedRefs.has(ref))) { - hasUndefinedVariables = true; - break; - } - } - - if (hasUndefinedVariables) { - // ensure we fix all undeclared vars, but only if new bindings have been introduced - ({ changes } = fixUndeclaredVars(updatedSource, requiredBindingsForChanges, mod)); - updatedSource = patchTextMorph && !runLint - ? applyChangesToTextMorph(sourceEditor, changes) - : applySourceChanges(updatedSource, changes); - } - - if (runLint) { - [updatedSource] = System.lint(updatedSource); - if (patchTextMorph) { - sourceEditor.textString = updatedSource; - } - } - - if (patchTextMorph) { - const browser = sourceEditor.owner; - if (browser?.isBrowser) browser.resetChangedContentIndicator(); - } - mod.setSource(updatedSource); - } -} - -/** - * Abstract class of reconciliation change that happens in response to a direct manipulation by the user. - * A reconciliation ensures that after it terminates, the component definitions are consistent with the - * state of the UI. A reconciliation is often covering several definitions and even modules at the same time, - * since components can be derived various times from different modules. - */ -export class Reconciliation { - static ensureNamesInSourceCode (componentDescriptor) { - new EnsureNamesReconciliation(componentDescriptor).reconcile().applyChanges(); // eslint-disable-line no-use-before-define - } - - static perform (componentDescriptor, change) { - let klass; - - componentDescriptor.ensureNamesInSourceCode(); - - if (change.prop) { - klass = change.prop === 'name' ? RenameReconciliation : PropChangeReconciliation; // eslint-disable-line no-use-before-define - } - - if (change.selector === 'addMorphAt') { - klass = MorphIntroductionReconciliation; // eslint-disable-line no-use-before-define - } - - if (change.selector === 'removeMorph') { - klass = MorphRemovalReconciliation; // eslint-disable-line no-use-before-define - } - - if (change.prop === 'textAndAttributes' || - change.selector === 'replace' || - change.selector === 'addTextAttribute') { - klass = TextChangeReconciliation; // eslint-disable-line no-use-before-define - // handle both things in the same class? - } - - return new klass(componentDescriptor, change).reconcile().applyChanges(); - } - - constructor (componentDescriptor, change) { - this.changesByModule = []; - this.requiredBindingsByModule = new Map(); // for any of the changes the accumulated bindings that are required to fullfill the reconciliation - this.descriptor = componentDescriptor; // the descriptor of the component definition - this.modulesToLint = new Set(); // wether or not the changes in the source code require the linter in a final pass - this.change = change; - } - - // wether or not we are the definition the change originated from (in case of propagation) - isOrigin (descriptor) { return this.descriptor === descriptor; } - - get target () { return this.change?.target; } - - get System () { return this.descriptor.System; } - - get isDerived () { return this.withinDerivedComponent(this.target); } - - /** - * If present, returns the first browser that has unsaved changes and - * the module openend that the component we are tracking is defined in. - * @type { Text } - */ - getEligibleSourceEditors (modId, modSource) { - return getEligibleSourceEditorsFor(modId, modSource); - } - - recoverRemovedMorphMetaIn (interactiveDescriptor) { - return this.policyToSpecAndSubExpressions?.get(exprSerializer.exprStringEncode(interactiveDescriptor.__serialize__())); - } - - getDescriptorContext (descr = this.descriptor) { - if (!this._context) this._context = new Map(); - if (this._context.has(descr)) return this._context.get(descr); - const modId = System.decanonicalize(descr.moduleName); - - let sourceCode = descr.getModuleSource(); - let openEditors; - const [openEditor] = openEditors = this.getEligibleSourceEditors(modId, sourceCode); - if (openEditor) sourceCode = openEditor.textString; - - const parsedModule = parse(sourceCode); - const scope = query.topLevelDeclsAndRefs(parsedModule).scope; - const parsedComponent = descr.getASTNode(parsedModule); - const requiredBindings = this.requiredBindingsByModule.get(modId) || []; - if (!this.requiredBindingsByModule.has(modId)) this.requiredBindingsByModule.set(modId, requiredBindings); - const ctx = { modId, parsedComponent, sourceCode, requiredBindings, openEditor, openEditors, scope }; - this._context.set(descr, ctx); - return ctx; - } - - withinDerivedComponent (aMorph, includeSelf = false) { - return isWithinDerivedComponent(aMorph, includeSelf); - } - - addChangesToModule (moduleName, newChanges) { - this.changesByModule.push([moduleName, newChanges]); - } - - uncollapseSubmorphHierarchy (hiddenSubmorphExpr = false) { - const hiddenMorph = this.target; - const { modId, sourceCode, parsedComponent, requiredBindings } = this.getDescriptorContext(); - const { changes, needsLinting, bindings } = uncollapseSubmorphHierarchy(sourceCode, parsedComponent, hiddenMorph, hiddenSubmorphExpr); - requiredBindings.push(...Object.entries(bindings)); - if (needsLinting) this.modulesToLint.add(modId); - this.addChangesToModule(modId, changes); - return this; - } - - /** - * Apply the recorded changes to the source code of the affected modules. - * @param { Text } [editor] - Text morph that stores the source code of the module, which can be altered instead of talking to the module object. - * @returns { Reconciliation } - */ - applyChanges () { - const { openEditors, scope } = this.getDescriptorContext(); - - if (openEditors.length > 0) { - openEditors.map(ed => applyModuleChanges(this, scope, this.System, ed)); - } else { - applyModuleChanges(this, scope, this.System); - } // no open editors - - return this; - } - - reconcile () { - notYetImplemented(this.constructor.name + '.reconcile()'); - return this; - } -} - -class EnsureNamesReconciliation extends Reconciliation { - get spec () { - return this.descriptor.stylePolicy.spec; - } - - get target () { - return this.descriptor._cachedComponent; - } - - reconcile () { - const { modId, sourceCode, parsedComponent } = this.getDescriptorContext(); - const anonymousSpecs = getAnonymousSpecs(parsedComponent); - const anonymousParts = getAnonymousParts(parsedComponent); - const anonymousAddedParts = getAnonymousAddedParts(parsedComponent); - const rootNode = getPropertiesNode(parsedComponent); - // now traverse the specs and the parsed component in tandem - tree.mapTree([this.spec, rootNode], ([currentSpec, currentNode]) => { - if (currentNode === rootNode) return; - const propNode = getPropertiesNode(currentNode); - const generatedName = currentSpec.props?.name || currentSpec.name; - if (propNode && anonymousSpecs.includes(propNode) && generatedName) { - this.addChangesToModule(modId, insertPropChange( - sourceCode, - propNode, - 'name', - `'${generatedName}'` - )); - return; - } - if (anonymousParts.includes(currentNode)) { - // insert a name prop object next to the identifier - this.addChangesToModule(modId, [{ - action: 'insert', - start: currentNode.arguments[0].end, - lines: [`, { name: '${generatedName}' }`] - }]); - } - if (anonymousAddedParts.includes(currentNode)) { - // insert a name prop object - this.addChangesToModule(modId, [{ - action: 'insert', - start: currentNode.arguments[0].arguments[0].end, - lines: [`, { name: '${generatedName}' }`] - }]); - } - }, ([specOrPolicy, node]) => { - // the node may not be mentioned in the code, when we are in a derived component - const subNodes = node && getProp(getPropertiesNode(node), 'submorphs')?.value?.elements; - const subSpecs = [...specOrPolicy.isPolicy - ? specOrPolicy.spec.submorphs - : (specOrPolicy.props?.submorphs || specOrPolicy.submorphs)]; - if (subNodes && subSpecs) { - const specToNodeMapping = new Map(); - for (let spec of subSpecs) { - // 1. - // first gather all of the nodes for specs that are inherited - // if these cant be found in the code, the nodes are declared not present - if (spec.COMMAND !== 'add' && spec.name) { - let match = subNodes.find(node => getProp(getPropertiesNode(node), 'name')?.value.value === spec.name); - if (match) { - specToNodeMapping.set(spec, match); - arr.remove(subNodes, match); - arr.remove(subSpecs, spec); - } - // if the spec is in a derived context, then this can be dropped - if (this.withinDerivedComponent(this.target.getSubmorphNamed(spec.name))) { arr.remove(subSpecs, spec); } - } - } - - for (let spec of subSpecs) { - // 2. - // now gather all of the specs for specs that were added to derived. - // these have to be present in the code, if they cant be found this is an error. - // In case we encounter anonymous added specs, we need to map them by order in the 3rd step. - - // at this point we can assume the all remaing specs are added ones - if (spec.props?.name) { - let match = subNodes.find(node => getProp(node, 'name')?.value.value === spec.props.name); - if (match) { - specToNodeMapping.set(spec, match); - arr.remove(subNodes, match); - } - arr.remove(subSpecs, spec); - } - } - // 3. - // we have now mapped all of the specs to nodes via name - // we are now left with the remaining specs and anonymous nodes, which we map 1 - 1 based on order - return [...specToNodeMapping.entries(), ...arr.zip(subSpecs, subNodes)]; - } - return null; - }); - return this; - } -} - -/** - * Reconciliation that handles the case where the a morph is removed from a component definition. - * This usual entails removing the spec that corresponds to that morph, and also removing the mentions - * of the morph or any of its submorphs in the derived component definitions. - */ -class MorphRemovalReconciliation extends Reconciliation { - constructor (componentDescriptor, change) { - super(componentDescriptor, change); - this.policyToSpecAndSubExpressions = this.descriptor.previouslyRemovedMorphs?.get(this.removedMorph) || new Map(); - } - - reconcile () { - this.descriptor.recordRemovedMorph(this.removedMorph, this.policyToSpecAndSubExpressions); - this.removeSpec(this.descriptor); - return this; - } - - get removedMorph () { return this.change.args[0]; } - get previousOwner () { return this.target; } - get isDerived () { return this.withinDerivedComponent(this.target, true); } - - /** - * Reconciles the removal of a morph with the replacement or insertation of a without() call that denotes - * the structural change in the structure inherited from the parent component. - * @param { InteractiveDescriptor } interactiveDescriptor - The component descriptor of the definition getting reconciled. - */ - insertWithoutCall (interactiveDescriptor) { - const { previousOwner, removedMorph } = this; - const { modId, sourceCode, parsedComponent, requiredBindings } = this.getDescriptorContext(interactiveDescriptor); - - let closestSubmorphsNode = getProp(getMorphNode(parsedComponent, previousOwner), 'submorphs'); - let nodeToRemove = closestSubmorphsNode && getNodeFromSubmorphs(closestSubmorphsNode.value, removedMorph.name); - - const removeMorphExpr = { - __expr__: `without('${ removedMorph.name }')`, - bindings: { [COMPONENTS_CORE_MODULE]: ['without'] } - }; - requiredBindings.push(...Object.entries(removeMorphExpr.bindings)); - let changes = []; - let needsLinting = false; - if (nodeToRemove) { - changes.push(Object.assign({ action: 'replace' }, nodeToRemove, { lines: [removeMorphExpr.__expr__] })); - } else { - ({ needsLinting, changes } = insertMorphExpression(parsedComponent, sourceCode, previousOwner, removeMorphExpr)); - } - - const addCallToAdjust = closestSubmorphsNode && getAddCallReferencing(closestSubmorphsNode.value, removedMorph); - if (addCallToAdjust) { - // remove the before string including the comma - const nameToRemove = addCallToAdjust.arguments[1]; - let start = nameToRemove.start; - while (sourceCode[start] !== ',') start--; - changes.push({ action: 'remove', start, end: nameToRemove.end }); - } - - if (needsLinting) this.modulesToLint.add(modId); - - return changes; - } - - /** - * Removes a morph from the 'submorphs' property of a component definition. - * If there's only one morph left in the 'submorphs' array, the entire 'submorphs' property will be removed. - * The method updates the changes array with the appropriate - * removal actions and marks the associated module for linting. - * @param {type} interactiveDescriptor - The descriptor pointing to the affected component definition. - */ - dropSpec (interactiveDescriptor) { - const { previousOwner, removedMorph } = this; - const { modId, parsedComponent } = this.getDescriptorContext(interactiveDescriptor); - let closestSubmorphsNode = getProp(getMorphNode(parsedComponent, previousOwner), 'submorphs'); - let nodeToRemove = closestSubmorphsNode && getNodeFromSubmorphs(closestSubmorphsNode.value, removedMorph.name); - - const removedExpr = nodeToRemove && this.getRemovedExpression(nodeToRemove); - - const changes = []; - if (nodeToRemove && closestSubmorphsNode?.value.elements.length < 2) { - this.modulesToLint.add(modId); - changes.push(Object.assign({ action: 'remove' }, determineNodeToRemoveSubmorphs(closestSubmorphsNode, parsedComponent, previousOwner))); - } else if (nodeToRemove) { - changes.push(Object.assign({ action: 'remove' }, nodeToRemove)); - } - return [changes, removedExpr]; - } - - /** - * Applies the source code transformation to the definition of the component - * where the change originated from. We need to differentiate between alteration - * of an interhited structure via `without()` or the simple removal of a spec (add() or part() or {}) - * from the submorphs array in the component definition. - * @param { InteractiveDescriptor } interactiveDescriptor - The descriptor of the component definition the change originated from. - */ - applyRemovalToOrigin (interactiveDescriptor) { - if (this.removedMorphWasInherited) return [this.insertWithoutCall(interactiveDescriptor)]; - else return this.dropSpec(interactiveDescriptor); - } - - get removedFromOriginalContext () { - const meta = this.recoverRemovedMorphMetaIn(this.descriptor); - return meta?.wasInherited && this.previousOwner === meta.previousOwner; - } - - get removedMorphWasInherited () { - return this.isDerived && ( - !this.removedMorph.__wasAddedToDerived__ || - this.removedFromOriginalContext - ); - } - - /** - * Apply the source code transformation to the definition of a component - * *derived* from the component where the change originated from. - * @param {type} interactiveDescriptor - description - */ - applyRemovalToDependant (interactiveDescriptor) { - // we ALWAYS just drop the spec, regardless of the circumstances - return this.dropSpec(interactiveDescriptor); - } - - getRemovedExpression (removeExprChange) { - let subExpr = this.descriptor.getModuleSource().slice(removeExprChange.start, removeExprChange.end); - try { - const [exprBody] = parse(subExpr.startsWith('{') ? `(${subExpr})` : subExpr).body; - if (exprBody.type === 'LabeledStatement') { - // extract the removed element from the elements - const [removedSpec] = exprBody.body.expression.elements; - subExpr = subExpr.slice(removedSpec.start, removedSpec.end); - } - if (subExpr.startsWith('add')) { - // extract the removed element from the elements - const [removedSpec] = exprBody.expression.arguments; - subExpr = subExpr.slice(removedSpec.start, removedSpec.end); - } - } finally { - return { __expr__: subExpr, bindings: [] }; - } - } - - removeSpec (interactiveDescriptor) { - let changes, subExpr; - const isChangeOrigin = this.isOrigin(interactiveDescriptor); - const insertWithoutCall = isChangeOrigin && this.removedMorphWasInherited; - - if (isChangeOrigin) [changes, subExpr] = this.applyRemovalToOrigin(interactiveDescriptor); - else [changes, subExpr] = this.applyRemovalToDependant(interactiveDescriptor); - - const subSpec = interactiveDescriptor.stylePolicy.removeSpecInResponseTo(this.change, insertWithoutCall); - let activeInstance = interactiveDescriptor._cachedComponent; - - // cache the meta information about the removed morph/spec/expression (the trinity) - let meta = this.recoverRemovedMorphMetaIn(interactiveDescriptor) || { wasInherited: this.removedMorphWasInherited }; - - if (activeInstance) { - activeInstance.withMetaDo({ reconcileChanges: false }, () => { - interactiveDescriptor.stylePolicy.withSubmorphsInScopeDo(activeInstance, (m) => { - if (m.name === this.removedMorph.name) { - m.remove(); - meta.removedMorph = m; - } - }); - }); - } - - // the morph was part of the original component, not any derivation - if (!this.removedMorph.__wasAddedToDerived__) meta.previousOwner = this.previousOwner; - - if (subSpec) meta.subSpec = subSpec; - - if (subExpr) meta.subExpr = subExpr; - - if (!obj.isEmpty(meta)) { - this.policyToSpecAndSubExpressions.set( - exprSerializer.exprStringEncode(interactiveDescriptor.__serialize__()), - meta); - } - - this.addChangesToModule(interactiveDescriptor.moduleName, changes); - - interactiveDescriptor.withDerivedComponentsDo(derivedDescr => { - this.removeSpec(derivedDescr); - }); - } -} - -/** - * Reconciliation that handles the case where a morph is introduced into a component definition. - * This can be a copletely new morph or one that was previously removed from the component in question - */ -class MorphIntroductionReconciliation extends Reconciliation { - reconcile () { - const { descriptor, addedMorph } = this; - this.fixNameCollisions(descriptor, addedMorph); - - if (this.isReintroduction(descriptor)) { - this.reintroduceMorph(descriptor); - } else { - this.addNewMorph(descriptor); - descriptor.withDerivedComponentsDo(derivedDescr => { - this.updateActiveSessionsFor(derivedDescr); - }); - } - return this; - } - - adjustNameIfNeeded (aMorph, newName) { - if (newName !== aMorph.name) { - aMorph.withMetaDo({ reconcileChanges: false }, () => { - aMorph.name = newName; // do not reconcile this - }); - } - } - - fixNameCollisions (stylePolicyOrDescriptor, rootMorph) { - rootMorph.withAllSubmorphsDoExcluding(m => { - // this does not work for inline components - const safeName = stylePolicyOrDescriptor.ensureNoNameCollisionInDerived(m.name); - if (m.master && m.master !== stylePolicyOrDescriptor) { - m.withAllSubmorphsDo(sub => { - if (sub.__wasAddedToDerived__) { - this.adjustNameIfNeeded(sub, m.master.ensureNoNameCollisionInDerived(sub.name)); - } - }); - } - this.adjustNameIfNeeded(m, safeName); - }, m => m.master); - } - - get isDerived () { return this.withinDerivedComponent(this.target, true); } - get addedMorph () { return this.change.args[0]; } - get newOwner () { return this.target; } - get nextSibling () { return this.newOwner.submorphs[this.newOwner.submorphs.indexOf(this.addedMorph) + 1]; } - - get policyToSpecAndSubExpressions () { - return this.descriptor.previouslyRemovedMorphs?.get(this.addedMorph); - } - - /** - * Wether or not the morph added to the definition - * had been there previously. - */ - isReintroduction (interactiveDescriptor) { - // store the info of previously removed morphs in a history object? - if (!this.policyToSpecAndSubExpressions) return false; - const meta = this.recoverRemovedMorphMetaIn(interactiveDescriptor); - if (!meta.subExpr) return meta.previousOwner === this.newOwner; - return true; - } - - generateAddedMorphExpression (addedMorph, nextSibling, requiredBindings) { - let expr = convertToExpression(addedMorph, { dropMorphsWithNameOnly: false }); - - if (addedMorph.master) { - const metaInfo = addedMorph.master.parent[Symbol.for('lively-module-meta')]; - expr = convertToExpression(addedMorph, { - exposeMasterRefs: false, - skipAttributes: [...DEFAULT_SKIPPED_ATTRIBUTES, 'type'] - }); - expr = { - // this fails when components are alias imported.... - // we can not insert the model props right now - // this also serializes way too much - __expr__: `part(${metaInfo.exportedName}, ${expr.__expr__})`, - bindings: { - ...expr.bindings, - [COMPONENTS_CORE_MODULE]: ['part'], - [metaInfo.moduleId]: [metaInfo.exportedName] - } - }; - } - - if (this.isDerived) { - addedMorph.__wasAddedToDerived__ = true; - expr.__expr__ = `add(${expr.__expr__}${nextSibling ? `, "${nextSibling.name}"` : ''})`; - const b = expr.bindings[COMPONENTS_CORE_MODULE] || []; - b.push('add'); - expr.bindings[COMPONENTS_CORE_MODULE] = b; - } - - requiredBindings.push(...Object.entries(expr.bindings)); - return expr; - } - - reintroduceSpec (interactiveDescriptor, spec) { - const insertedSpec = interactiveDescriptor.stylePolicy.ensureSubSpecFor(this.addedMorph); - Object.assign(insertedSpec, spec); - } - - reintroduceExpression (interactiveDescriptor, expr) { - this.addNewMorph(interactiveDescriptor, expr); // basically the same as just adding the morph but with a fixed expression - } - - insertMorphInOpenSession (interactiveDescriptor, morphToAdd) { - const activeInstance = interactiveDescriptor._cachedComponent; - if (!activeInstance) return; - activeInstance.withMetaDo({ reconcileChanges: false }, () => { - interactiveDescriptor.stylePolicy.withSubmorphsInScopeDo(activeInstance, (m) => { - if (obj.equals(getPathFromMorphToMaster(m), getPathFromMorphToMaster(this.newOwner))) { - m.addMorph(morphToAdd, this.nextSibling ? m.getSubmorphNamed(this.nextSibling.name) : null); - } - }); - }); - } - - /** - * If a morph is reintroduced that was previously reified via a - * without() call in the same owner it was removed from, we need - * to simply remove the without() call instead of adding the spec - * to the source code. - * @param {type} interactiveDescriptor - description - */ - clearWithoutCallIfNeeded (interactiveDescriptor) { - const { modId, parsedComponent } = this.getDescriptorContext(interactiveDescriptor); - let closestSubmorphsNode = getProp(getMorphNode(parsedComponent, this.newOwner), 'submorphs'); - let nodeToRemove; - if (closestSubmorphsNode?.value.elements.length < 2) { - this.modulesToLint.add(modId); - nodeToRemove = determineNodeToRemoveSubmorphs(closestSubmorphsNode, parsedComponent, this.newOwner.isComponent ? null : this.newOwner); - } else { - nodeToRemove = closestSubmorphsNode && getWithoutCall(closestSubmorphsNode.value, this.addedMorph); - } - - interactiveDescriptor.stylePolicy.removeWithoutCall(this.addedMorph); - // we also need to reintroduce the removed spec - - if (nodeToRemove) { - if (nodeToRemove === getPropertiesNode(parsedComponent)) { - this.addChangesToModule(modId, Object.assign({ action: 'replace', ...nodeToRemove, lines: ['{}'] })); - } else { - this.addChangesToModule(modId, Object.assign({ action: 'remove' }, nodeToRemove)); - } - } - } - - reintroduceMorph (interactiveDescriptor) { - // recover the source code from the removed morph and reinsert it at the new position - const meta = this.recoverRemovedMorphMetaIn(interactiveDescriptor); - if (meta) { - let { subSpec: removedSpec, subExpr: removedExpr, removedMorph, previousOwner, wasInherited } = meta; - if (removedSpec?.__wasAddedToDerived__ && previousOwner !== this.newOwner) { - removedExpr = this.generateAddedMorphExpression(this.addedMorph, this.nextSibling, []); - } - - if (wasInherited && this.newOwner === previousOwner) { - this.clearWithoutCallIfNeeded(interactiveDescriptor); - } - // add the spec that was discarded previously into the policy - this.reintroduceSpec(interactiveDescriptor, removedSpec); - // add the expr that was discarded previously into the policy - if ((previousOwner !== this.newOwner || !wasInherited) && removedExpr) { - this.reintroduceExpression(interactiveDescriptor, removedExpr); - } - if (removedMorph) { - this.insertMorphInOpenSession(interactiveDescriptor, removedMorph); - } - } - // also propagate among dependants, since that means we reintroduce the old specs alongside their custom code - interactiveDescriptor.withDerivedComponentsDo(derivedDescr => { - this.reintroduceMorph(derivedDescr); - }); - } - - addNewMorph (interactiveDescriptor, addedMorphExpr) { - const { newOwner, addedMorph, nextSibling } = this; - - const { modId, parsedComponent, sourceCode, requiredBindings } = this.getDescriptorContext(interactiveDescriptor); - - if (!addedMorphExpr) { - addedMorphExpr = this.generateAddedMorphExpression(addedMorph, nextSibling, requiredBindings); - } - - const { changes, needsLinting } = insertMorphExpression(parsedComponent, sourceCode, newOwner, addedMorphExpr, nextSibling); - if (needsLinting) this.modulesToLint.add(modId); - - this.addChangesToModule(modId, changes); - - // determine the responsible style policy - let policyForScope = interactiveDescriptor.stylePolicy.getSubPolicyFor(addedMorph.owner) || interactiveDescriptor.stylePolicy; - if (addedMorph.owner.master === interactiveDescriptor.stylePolicy) { policyForScope = interactiveDescriptor.stylePolicy; } - const subSpec = policyForScope.ensureSubSpecFor(addedMorph, this.isDerived); - if (nextSibling) subSpec.before = nextSibling.name; - } - - updateActiveSessionsFor (interactiveDescriptor) { - this.insertMorphInOpenSession(interactiveDescriptor, this.addedMorph.copy()); - interactiveDescriptor.stylePolicy.ensureSubSpecFor(this.addedMorph); - interactiveDescriptor.withDerivedComponentsDo(derivedDescr => { - this.updateActiveSessionsFor(derivedDescr); - }); - } -} - -/** - * Reconciles the code in response to a change in one of the properties - * in the component definition. - */ -class PropChangeReconciliation extends Reconciliation { - get newValue () { - return this.change.value; - } - - /** - * Checks if a given morph's height is dictated - * by a layout. In those cases, reconciling the entire - * extent is skipped and we resort to reconciling the - * `width` property if applicable. - * @param { Morph } aMorph - The morph to check for - * @returns { boolean } - */ - isResizedVertically (aMorph) { - const l = aMorph.isLayoutable && aMorph.owner && aMorph.owner.layout; - return l && l.resizesMorphVertically(aMorph); - } - - /** - * Checks if a given morph's width is dictated - * by a layout. In those cases, reconciling the entire - * extent is skipped and we resort to reconciling the - * `height` property if applicable. - * @param { Morph } aMorph - The morph to check for - * @returns { boolean } - */ - isResizedHorizontally (aMorph) { - const l = aMorph.isLayoutable && aMorph.owner && aMorph.owner.layout; - return l && l.resizesMorphHorizontally(aMorph); - } - - handleExtentChange (subSpec, specNode) { - const { newValue, target } = this; - let changedProp = 'extent'; - let deleteWidth = false; - let deleteHeight = false; - let valueExpr = this.getExpressionOfValue(); - if (this.isResizedVertically(target)) { - changedProp = 'width'; - valueExpr = String(newValue.x); - deleteHeight = true; - } - if (this.isResizedHorizontally(target)) { - changedProp = 'height'; - valueExpr = String(newValue.y); - deleteWidth = true; - } - if (deleteHeight) { - this.deletePropIn(specNode, 'height'); - } - if (deleteWidth) { - this.deletePropIn(specNode, 'width'); - } - if (deleteWidth || deleteHeight) { - this.deletePropIn(specNode, 'extent'); - } - this.patchPropIn(specNode, changedProp, valueExpr); - subSpec.extent = newValue; - return this; - } - - getSubSpecForTarget () { - const policy = this.descriptor.stylePolicy; - if (this.target.master === policy || this.target.isComponent) return policy.spec; - // what if this is a root component? Then it does not have any master. - // this does not work if the target is not part of the component scope. - // instead we need to get the path to the target - const scopePolicy = this.getResponsiblePolicyFor(this.target); - const spec = scopePolicy.getSubSpecFor(this.target.name); - if (spec.isPolicy) return spec.spec; - return spec; - } - - getNodeForTargetInSource (interactiveDescriptor = this.descriptor) { - const { parsedComponent } = this.getDescriptorContext(interactiveDescriptor); - const morphNode = getMorphNode(parsedComponent, this.target); - return morphNode && getPropertiesNode(morphNode); - } - - patchPropIn (specNode, prop, valueAsExpr) { - if (!valueAsExpr) return this; - const { modId, sourceCode } = this.getDescriptorContext(); - if (valueAsExpr.__expr__) valueAsExpr = valueAsExpr.__expr__; - - const propNode = getProp(specNode, prop); - - if (!propNode) { - // this is an uncollapse so we need to lint the module - this.modulesToLint.add(modId); - this.addChangesToModule(modId, insertPropChange( - sourceCode, - specNode, - prop, - valueAsExpr - )); - return this; - } - - const patchPos = propNode.value; - this.addChangesToModule(modId, [ - { action: 'replace', ...patchPos, lines: [valueAsExpr] } - ]); - - return this; - } - - deletePropIn (subSpec, prop, eraseIfEmpty = this.isDerived) { - const { modId, sourceCode, parsedComponent } = this.getDescriptorContext(); - const { changes, needsLinting } = deleteProp(sourceCode, parsedComponent, subSpec, prop, this.target, eraseIfEmpty); - if (needsLinting) this.modulesToLint.add(modId); - this.addChangesToModule(modId, changes); - return this; - } - - getResponsiblePolicyFor (target) { - const policy = this.descriptor.stylePolicy.getSubPolicyFor(target) || this.descriptor.stylePolicy; - if (!policy.isPolicy) return this.descriptor.stylePolicy; - return policy; - } - - get propValueDiffersFromParent () { - let { target, prop } = this.change; - const policy = this.getResponsiblePolicyFor(target); - const { parent, targetMorph } = policy; - let val; - if (parent) { - let synthesized = parent.synthesizeSubSpec(target === targetMorph ? null : target.name); - if (synthesized.isPolicy) synthesized = synthesized.synthesizeSubSpec(); - val = synthesized[prop]; - } - if (typeof val === 'undefined') { - const { type } = this.getSubSpecForTarget(); - val = getDefaultValueFor(type, prop); - } - return !obj.equals(val, this.newValue); - } - - getExpressionOfValue (depth = 1) { - const { target, prop, value } = this.change; - const { requiredBindings } = this.getDescriptorContext(); - let valueAsExpr, members; - if (members = isFoldableProp(target.constructor, prop)) { - valueAsExpr = getFoldableValueExpr(prop, value, members, target.ownerChain().length); - } else { - valueAsExpr = getValueExpr(prop, value, depth); - } - if (valueAsExpr) { requiredBindings.push(...Object.entries(valueAsExpr.bindings)); } - return valueAsExpr; - } - - handleMasterChange (subSpec, specNode, depth) { - const { target, newValue } = this; - const responsiblePolicy = this.getResponsiblePolicyFor(target); - if (!newValue) { - // clear all of the fields here - if (subSpec === responsiblePolicy.spec) responsiblePolicy.reset(); // assumes it is a policy - } - if (newValue) { - // then we want to replace the sub spec with a policy (in case the spec is not a policy) - if (subSpec === responsiblePolicy.spec) { - // assign masters to the policy - responsiblePolicy.applyConfiguration(newValue); - } else { - // convert spec into policy and replace it - // we can be sure, that the subSpec *is not* itself a policy - // because in that case, that other policy would be called to - // get the enclosing spec... - let parentSpec = responsiblePolicy.getSubSpecCorrespondingTo(target.owner); - if (parentSpec.isPolicy) parentSpec = parentSpec.spec; - parentSpec.submorphs[parentSpec.submorphs.indexOf(subSpec)] = PolicyApplicator.for(target, { - ...subSpec, - master: newValue - }); - } - if (this.propValueDiffersFromParent) { - return this.patchPropIn(specNode, 'master', this.getExpressionOfValue(depth)); - } - } - return this.deletePropIn(specNode, 'master'); - } - - reconcile () { - let { prop, target } = this.change; - const specNode = this.getNodeForTargetInSource(); - - if (prop === 'name') { - throw new Error('Cannot handle renaming in a policy reconciliation, since it consitutes a structural change. Use the RenameReconcilation instead.'); - } - - if (!specNode) { - // what if we have not yet processed the add call? - if (!this.isDerived) return this; - return this.uncollapseSubmorphHierarchy(); - } - - const tabSize = 2; - const indentDepth = specNode.properties.length > 0 ? (specNode.properties[0].start - specNode.start - 2) / tabSize : 1; - const subSpec = this.getSubSpecForTarget(); - - if (prop === 'master') { - return this.handleMasterChange(subSpec, specNode, indentDepth); - } - - if (prop === 'extent') { - return this.handleExtentChange(subSpec, specNode); - } - - subSpec[prop] = this.change.value; - - this.propagateChangeAmongActiveEditSessions(this.descriptor); - // update the source code - if (this.propValueDiffersFromParent) { - return this.patchPropIn(specNode, prop, this.getExpressionOfValue(indentDepth)); - } - - delete subSpec[prop]; - return this.deletePropIn(specNode, prop); - } - - propagateChangeAmongActiveEditSessions (interactiveDescriptor) { - let activeInstance; - interactiveDescriptor.withDerivedComponentsDo(descr => { - if (activeInstance = descr._cachedComponent) { - activeInstance.withMetaDo({ reconcileChanges: false }, () => { - activeInstance.master.applyIfNeeded(true); - }); - } - this.propagateChangeAmongActiveEditSessions(descr); - }); - } -} - -/** - * In case a morph is getting renamed, this constitues a structural change since all of the references - * in the derived components need to be updated in turn in order to still be consistent. - * The reconciliation also makes sure, that the new name itself does not collide with other morphs in any of the derived policies. - */ -class RenameReconciliation extends PropChangeReconciliation { - get oldName () { return this.change.prevValue; } - get newName () { return string.camelCaseString(this.newValue); } - get renamedMorph () { return this.change.target; } - get renameComponent () { return this.target.master === this.descriptor.stylePolicy || this.target.isComponent; } - - getSubSpecForTarget (interactiveDescriptor) { - return interactiveDescriptor.stylePolicy.getSubSpecFor(this.oldName); - } - - getNodeForTargetInSource (interactiveDescriptor) { - const { parsedComponent } = this.getDescriptorContext(interactiveDescriptor); - const affectedPolicy = getMorphNode(parsedComponent, this.target.owner); - return getPropertiesNode(affectedPolicy, this.oldName); - } - - /** - * Reconciles the definition of a component in response to a renaming of a morph in the visual instance of the component. - * Renaming derived morphs currently has *no* effect on the source, since it is prohibited by the halo. - * @param { StylePolicy } affectedPolicy - The affected policy where we need to adjust the spec. - * @param { Object } subSpec - The spec to adjust. - * @returns { PropChangeReconciliation } The reconciliator object. - */ - handleRenaming (interactiveDescriptor, local = true) { - this._backups.push(interactiveDescriptor.ensureComponentDefBackup()); - let subSpec = this.getSubSpecForTarget(interactiveDescriptor); - if (!local) { - // only proceed to patch the subSpec, if we are really derived! - if (subSpec?.__wasAddedToDerived__) subSpec = false; - } - - if (subSpec) { - subSpec.name = string.decamelize(this.newName); // rename the spec object, since it is present - const specNode = this.getNodeForTargetInSource(interactiveDescriptor); - if (specNode) this.patchPropIn(specNode, 'name', this.getExpressionOfValue()); - } - - this.patchOwnerLayoutIfNeeded(interactiveDescriptor); - - // renaming is a structural change and requires propagation of the changes - interactiveDescriptor.withDerivedComponentsDo(derivedDescr => { - this.handleRenaming(derivedDescr, false); - }); - - return this; - } - - patchOwnerLayoutIfNeeded (interactiveDescriptor) { - const { parsedComponent } = this.getDescriptorContext(interactiveDescriptor); - const affectedPolicy = getMorphNode(parsedComponent, this.target.owner); - const parentNode = getPropertiesNode(affectedPolicy, this.target.owner); - const parentSpec = interactiveDescriptor.stylePolicy.getSubSpecFor(!this.target.owner?.isComponent ? this.target.owner : null); - if (parentSpec?.layout && parentNode) { - parentSpec.layout.handleRenamingOf(this.oldName, this.newValue); - this.patchPropIn(parentNode, 'layout', parentSpec.layout.__serialize__()); - } - } - - reconcile () { - this._backups = []; - if (this.withinDerivedComponent(this.renamedMorph)) { - throw new Error('Cannot rename a morph that has not been introduced in this component! Please rename the morph in the component it originated from.'); - } - if (this.renameComponent) { - return this; - } - - this.handleRenaming(this.descriptor); - return this; - } - - async applyChanges () { - await Promise.all(this._backups); - super.applyChanges(); - if (this.renameComponent) { - const newMorph = await renameComponent(this.renamedMorph, this.newName, this.System); - if (!this.renamedMorph.world()) return; - newMorph.openInWorld(); - newMorph.position = this.renamedMorph.position; - if ($world.halos().find(h => h.target === this.renamedMorph)) $world.showHaloFor(newMorph); - this.renamedMorph.remove(); - - if (newMorph[Symbol.for('lively-module-meta')]?.moduleId === this.renamedMorph[Symbol.for('lively-module-meta')]?.moduleId) return; - const { openEditors } = this.getDescriptorContext(); - const newModId = System.decanonicalize(newMorph[Symbol.for('lively-module-meta')]?.moduleId); - openEditors.forEach(ed => { - const browser = ed.owner; - - browser.searchForModuleAndSelect(newModId); - }); - } - } -} - -/** - * In case the textAndAttributes, textString, value or input property of a text morph - * changes, this requires a specialized handling, since the text property itself can also - * include morphs. The text therefore constitutes a structural property, similar to the submorphs property. - */ -class TextChangeReconciliation extends PropChangeReconciliation { - reconcile () { - const { target: textMorph } = this.change; - const { requiredBindings, modId } = this.getDescriptorContext(); - const specNode = this.getNodeForTargetInSource(); - const styleSpec = this.getSubSpecForTarget(); - styleSpec.textAndAttributes = textMorph.textAndAttributes; - if (!specNode) { - this.uncollapseSubmorphHierarchy(); - return this; - } - // if textString/value are present, clear them and use textAndAttributes instead - const textAttrsAsExpr = getTextAttributesExpr(textMorph); - requiredBindings.push(...Object.entries(textAttrsAsExpr.bindings)); - const textStringProp = getProp(specNode, 'textString'); - const valueProp = getProp(specNode, 'value'); - if (textStringProp || valueProp) this.modulesToLint.add(modId); - if (textStringProp) this.deletePropIn(specNode, 'textString', false); // do not remove the entire node even if eligible for now - if (valueProp) this.deletePropIn(specNode, 'value', false); - this.patchPropIn(specNode, 'textAndAttributes', textAttrsAsExpr); - return this; - } - - getAstNodeAndAttributePositionInRange (specNode, pos, textAndAttributes) { - const textAttrProp = getProp(specNode, 'textAndAttributes'); - if (!textAttrProp) return {}; - if (this.target.textAndAttributes.length !== textAndAttributes.length) return {}; // attributes got added or deleted - if (this.target.textString.length === 0) return {}; // entire document got deleted - let attributeStart = 0; let j = 0; - const startIndex = this.target.positionToIndex(pos); - while (j < textAndAttributes.length && startIndex > attributeStart + textAndAttributes[j].length) { - attributeStart += textAndAttributes[j].length; - j += 2; - } - const stringNode = textAttrProp.value.elements[j]; - return { attributeStart, stringNode }; - } - - patchPropIn (specNode, propName, textAttrsAsExpr) { - const { modId } = this.getDescriptorContext(); - const { args, selector, undo, meta } = this.change; - const { prevTextAndAttributes } = meta; - delete meta.prevTextAndAttributes; // delete this huge array in order to save memory - const defaultPatch = () => { - this.modulesToLint.add(modId); - return super.patchPropIn(specNode, propName, textAttrsAsExpr); - }; - - if (!args) return defaultPatch(); - if (selector === 'replace') { - let [changedRange, attrReplacement] = args; - changedRange = Range.fromPositions(changedRange.start, changedRange.end); - const isDeletion = attrReplacement.length === 0 || attrReplacement[0] === '' && attrReplacement[1] === null; - const isReplacement = !isDeletion && !changedRange.isEmpty() && attrReplacement[0].length > 0; - const isInsertion = !isDeletion && !isReplacement && attrReplacement[0].length > 0; - const { attributeStart, stringNode } = this.getAstNodeAndAttributePositionInRange(specNode, isDeletion ? changedRange.end : changedRange.start, prevTextAndAttributes); - - if (!stringNode) return defaultPatch(); - - const manipulationStartIndex = this.target.positionToIndex(changedRange.start); - if (isDeletion) { - let deletionIndexInSource = stringNode.start + manipulationStartIndex - attributeStart + 1; - const deletedTextAndAttrs = undo.args[1]; - if (deletedTextAndAttrs.length > 2) { - // deletion of multiple text and attributes is too complex to reconcile efficiently - // perform the default patch instead; - return defaultPatch(); - } - // Count numbers of newlines that come **before** the deletion. As those are two characters in the module source (\n), - // we need to account for each of them with an additional character. - const lineBreakOffset = (stringNode.value.slice(0, manipulationStartIndex - attributeStart).match(/\n|\"|\'/g) || []).length; - deletionIndexInSource += lineBreakOffset; - const deleteCharacters = JSON.stringify(deletedTextAndAttrs[0]).slice(1, -1).replaceAll("'", "\\'").length; - this.addChangesToModule(modId, [{ - action: 'replace', - start: deletionIndexInSource, - end: deletionIndexInSource + deleteCharacters, - lines: [''] - }]); - return this; - } - - if (isReplacement) { - return defaultPatch(); - } - - if (isInsertion) { - let insertionIndexInSource = stringNode.start + manipulationStartIndex - attributeStart + 1; - // Count numbers of newlines that come **before** the insertion. As those are two characters in the module source (\n), - // we need to account for each of them with an additional character. - const lineBreakOffset = (stringNode.value.slice(0, manipulationStartIndex - attributeStart).match(/\n|\"|\'/g) || []).length; - insertionIndexInSource += lineBreakOffset; - this.addChangesToModule(modId, [{ - action: 'insert', - start: insertionIndexInSource, - lines: [JSON.stringify(attrReplacement[0]).slice(1, -1).replaceAll("'", "\\'")] - }]); - return this; - } - } - - return defaultPatch(); - } -} diff --git a/lively.ide/components/reconciliation/commands.js b/lively.ide/components/reconciliation/commands.js new file mode 100644 index 0000000000..6b9baa6161 --- /dev/null +++ b/lively.ide/components/reconciliation/commands.js @@ -0,0 +1,309 @@ +import { + addedNodeProvenance, + ComponentLayoutKind, + ComponentLayoutReferenceKind, + ComponentNode, + ComponentNodeProvenanceKind, + ComponentPropertyKind, + explicitProperty, + localNodeProvenance, + opaqueProperty, + resizePolicyLayoutReference, + tilingLayoutModel +} from './component-document.js'; +import { normalizeComponentImportBindings } from './import-bindings.js'; + +export const ComponentCommandKind = Object.freeze({ + SET_PROPERTY: 'set-property', + CLEAR_PROPERTY_OVERRIDE: 'clear-property-override', + RENAME_NODE: 'rename-node', + INTRODUCE_NODE: 'introduce-node', + MOVE_NODE: 'move-node', + REMOVE_NODE: 'remove-node', + SUPPRESS_INHERITED_NODE: 'suppress-inherited-node', + RESTORE_INHERITED_NODE: 'restore-inherited-node', + SET_MASTER: 'set-master', + EDIT_TEXT: 'edit-text' +}); + +export const ComponentTextEditKind = Object.freeze({ + REPLACE_ALL: 'replace-all' +}); + +export const ComponentMoveInheritanceTransitionKind = Object.freeze({ + MATERIALIZE: 'materialize-inherited', + RESTORE: 'restore-inherited' +}); + +const commandKinds = new Set(Object.values(ComponentCommandKind)); + +function validateId (id, name) { + if (typeof id !== 'string' || !id) throw new Error(`${name} requires a stable node ID`); +} + +function validateOptionalId (id, name) { + if (id !== null && id !== undefined) validateId(id, name); +} + +function validateOptionalName (value, name) { + if (value !== null && value !== undefined && + (typeof value !== 'string' || !value)) { + throw new Error(`${name} requires a non-empty ordering name`); + } +} + +function validateOptionalIndex (value, name) { + if (value !== null && value !== undefined && + (!Number.isInteger(value) || value < 0)) { + throw new Error(`${name} requires a non-negative runtime index`); + } +} + +function normalizeParentLayoutReference (state, commandName) { + if (state === null || state === undefined) return null; + if (!Number.isInteger(state.index) || state.index < 0 || + state.reference?.kind !== ComponentLayoutReferenceKind.RESIZE_POLICY) { + throw new Error(`${commandName} parentLayoutReference is invalid`); + } + return Object.freeze({ + index: state.index, + reference: resizePolicyLayoutReference(state.reference) + }); +} + +function normalizeSubtreeLayoutModels (states, node) { + if (states === null || states === undefined) return Object.freeze([]); + if (!Array.isArray(states)) { + throw new Error('IntroduceNode subtreeLayoutModels must be an array'); + } + const subtreeIds = new Set(); + const visit = current => { + subtreeIds.add(current.id); + current.children.forEach(visit); + }; + visit(node); + const ownerIds = new Set(); + let previousIndex = -1; + return Object.freeze(states.map(state => { + if (!Number.isInteger(state?.index) || state.index < 0 || + state.index <= previousIndex || state.model?.kind !== ComponentLayoutKind.TILING) { + throw new Error('IntroduceNode subtreeLayoutModels contains invalid state'); + } + const model = tilingLayoutModel(state.model); + if (!subtreeIds.has(model.ownerId) || ownerIds.has(model.ownerId) || + model.references.some(reference => !subtreeIds.has(reference.targetId))) { + throw new Error('IntroduceNode subtreeLayoutModels must belong to the introduced subtree'); + } + ownerIds.add(model.ownerId); + previousIndex = state.index; + return Object.freeze({ index: state.index, model }); + })); +} + +function command ({ kind, componentId, expectedRevision, nodeId, ...details }) { + if (!commandKinds.has(kind)) throw new Error(`Unknown component command kind: ${kind}`); + validateId(componentId, 'Component commands'); + validateId(nodeId, kind); + if (!Number.isInteger(expectedRevision) || expectedRevision < 0) { + throw new Error('Component commands require a non-negative expected revision'); + } + return Object.freeze({ kind, componentId, expectedRevision, nodeId, ...details }); +} + +export function SetPropertyEntry ({ + componentId, expectedRevision, nodeId, property, entry, requiredBindings = [] +}) { + if (typeof property !== 'string' || !property) { + throw new Error('SetProperty requires a property name'); + } + const validExplicit = entry?.kind === ComponentPropertyKind.EXPLICIT_VALUE && + Object.prototype.hasOwnProperty.call(entry, 'value'); + const validOpaque = entry?.kind === ComponentPropertyKind.OPAQUE_EXPRESSION && + typeof entry.expression === 'string' && !!entry.expression.trim(); + if (!validExplicit && !validOpaque) { + throw new Error('SetProperty requires an explicit or opaque property entry'); + } + const normalizedBindings = normalizeComponentImportBindings(requiredBindings); + if (validExplicit && normalizedBindings.length) { + throw new Error('Explicit component properties cannot require imports'); + } + return command({ + kind: ComponentCommandKind.SET_PROPERTY, + componentId, + expectedRevision, + nodeId, + property, + entry, + ...(validOpaque ? { requiredBindings: normalizedBindings } : {}) + }); +} + +export function SetProperty (spec) { + return SetPropertyEntry({ ...spec, entry: explicitProperty(spec.value) }); +} + +export function SetOpaqueProperty (spec) { + return SetPropertyEntry({ + ...spec, + entry: opaqueProperty(spec.expression), + requiredBindings: spec.requiredBindings + }); +} + +export function ClearPropertyOverride (spec) { + if (typeof spec.property !== 'string' || !spec.property) { + throw new Error('ClearPropertyOverride requires a property name'); + } + return command({ kind: ComponentCommandKind.CLEAR_PROPERTY_OVERRIDE, ...spec }); +} + +export function RenameNode (spec) { + if (typeof spec.name !== 'string' || !spec.name) throw new Error('RenameNode requires a name'); + return command({ kind: ComponentCommandKind.RENAME_NODE, ...spec }); +} + +export function IntroduceNode (spec) { + if (!(spec.node instanceof ComponentNode)) throw new Error('IntroduceNode requires a ComponentNode'); + validateId(spec.parentId, 'IntroduceNode'); + validateOptionalId(spec.beforeId, 'IntroduceNode'); + validateOptionalIndex(spec.runtimeIndex, 'IntroduceNode'); + if (spec.nodeId !== undefined && spec.nodeId !== spec.node.id) { + throw new Error('IntroduceNode nodeId must match the introduced node'); + } + const requiredBindings = normalizeComponentImportBindings(spec.requiredBindings || []); + const parentLayoutReference = normalizeParentLayoutReference( + spec.parentLayoutReference, + 'IntroduceNode' + ); + const subtreeLayoutModels = normalizeSubtreeLayoutModels( + spec.subtreeLayoutModels, + spec.node + ); + return command({ + kind: ComponentCommandKind.INTRODUCE_NODE, + ...spec, + requiredBindings, + ...(parentLayoutReference ? { parentLayoutReference } : {}), + ...(subtreeLayoutModels.length ? { subtreeLayoutModels } : {}), + nodeId: spec.node.id + }); +} + +export function MoveNode (spec) { + validateId(spec.parentId, 'MoveNode'); + validateOptionalId(spec.beforeId, 'MoveNode'); + validateOptionalName(spec.orderingName, 'MoveNode'); + validateOptionalIndex(spec.runtimeFromIndex, 'MoveNode runtimeFromIndex'); + validateOptionalIndex(spec.runtimeToIndex, 'MoveNode runtimeToIndex'); + const orderingRestorations = Object.freeze((spec.orderingRestorations || []).map(entry => { + validateId(entry?.nodeId, 'MoveNode ordering restoration'); + validateOptionalId(entry.beforeId, 'MoveNode ordering restoration'); + validateOptionalName(entry.beforeName, 'MoveNode ordering restoration'); + if (entry.beforeId && entry.beforeName) { + throw new Error('MoveNode ordering restoration cannot use both beforeId and beforeName'); + } + return Object.freeze({ + nodeId: entry.nodeId, + beforeId: entry.beforeId || null, + beforeName: entry.beforeName || null + }); + })); + const parentLayoutReference = normalizeParentLayoutReference( + spec.parentLayoutReference, + 'MoveNode' + ); + if (parentLayoutReference && parentLayoutReference.reference.targetId !== spec.nodeId) { + throw new Error('MoveNode parentLayoutReference must target the moved node'); + } + let provenance = null; + if (spec.provenance !== undefined) { + if (spec.provenance?.kind === ComponentNodeProvenanceKind.LOCAL) { + provenance = localNodeProvenance(); + } else if (spec.provenance?.kind === ComponentNodeProvenanceKind.ADDED) { + provenance = addedNodeProvenance(spec.provenance); + } else { + throw new Error('MoveNode provenance must be local or added'); + } + } + let inheritanceTransition = null; + if (spec.inheritanceTransition !== undefined) { + const transition = spec.inheritanceTransition; + if (transition?.kind === ComponentMoveInheritanceTransitionKind.MATERIALIZE && + transition.node instanceof ComponentNode) { + inheritanceTransition = Object.freeze({ + kind: transition.kind, + node: transition.node, + requiredBindings: normalizeComponentImportBindings( + transition.requiredBindings || [] + ) + }); + } else if (transition?.kind === ComponentMoveInheritanceTransitionKind.RESTORE) { + validateId(transition.inheritedNodeId, 'MoveNode inherited restoration'); + inheritanceTransition = Object.freeze({ + kind: transition.kind, + inheritedNodeId: transition.inheritedNodeId + }); + } else { + throw new Error('MoveNode inheritanceTransition is invalid'); + } + } + return command({ + kind: ComponentCommandKind.MOVE_NODE, + ...spec, + ...(provenance ? { provenance } : {}), + ...(inheritanceTransition ? { inheritanceTransition } : {}), + ...(parentLayoutReference ? { parentLayoutReference } : {}), + ...(orderingRestorations.length ? { orderingRestorations } : {}) + }); +} + +export function RemoveNode (spec) { + validateOptionalIndex(spec.runtimeIndex, 'RemoveNode'); + return command({ kind: ComponentCommandKind.REMOVE_NODE, ...spec }); +} + +export function SuppressInheritedNode (spec) { + return command({ kind: ComponentCommandKind.SUPPRESS_INHERITED_NODE, ...spec }); +} + +export function RestoreInheritedNode (spec) { + validateId(spec.parentId, 'RestoreInheritedNode'); + validateOptionalId(spec.beforeId, 'RestoreInheritedNode'); + return command({ kind: ComponentCommandKind.RESTORE_INHERITED_NODE, ...spec }); +} + +export function SetMaster (spec) { + const hasExpression = Object.prototype.hasOwnProperty.call(spec, 'expression'); + const hasValue = Object.prototype.hasOwnProperty.call(spec, 'value'); + if (hasExpression === hasValue) { + throw new Error('SetMaster requires exactly one of value or expression'); + } + const requiredBindings = normalizeComponentImportBindings(spec.requiredBindings); + if (hasValue && requiredBindings.length) { + throw new Error('Explicit component masters cannot require imports'); + } + return command({ + kind: ComponentCommandKind.SET_MASTER, + ...spec, + entry: hasExpression + ? opaqueProperty(spec.expression) + : explicitProperty(spec.value), + ...(hasExpression ? { requiredBindings } : {}) + }); +} + +export function EditText (spec) { + if (spec.operation?.kind !== ComponentTextEditKind.REPLACE_ALL || + !('before' in spec.operation) || !('after' in spec.operation)) { + throw new Error('EditText requires a replace-all operation with before and after values'); + } + return command({ + kind: ComponentCommandKind.EDIT_TEXT, + ...spec, + operation: Object.freeze({ + kind: ComponentTextEditKind.REPLACE_ALL, + before: explicitProperty(spec.operation.before).value, + after: explicitProperty(spec.operation.after).value + }) + }); +} diff --git a/lively.ide/components/reconciliation/component-document.js b/lively.ide/components/reconciliation/component-document.js new file mode 100644 index 0000000000..ce7ef51716 --- /dev/null +++ b/lively.ide/components/reconciliation/component-document.js @@ -0,0 +1,317 @@ +export const ComponentPropertyKind = Object.freeze({ + EXPLICIT_VALUE: 'explicit-value', + OPAQUE_EXPRESSION: 'opaque-expression' +}); + +export const ComponentNodeProvenanceKind = Object.freeze({ + LOCAL: 'local', + ADDED: 'added', + INHERITED: 'inherited' +}); + +export const ComponentReferenceKind = Object.freeze({ + SOURCE_EXPRESSION: 'source-expression' +}); + +export const ComponentLayoutKind = Object.freeze({ + TILING: 'tiling' +}); + +export const ComponentLayoutReferenceKind = Object.freeze({ + RESIZE_POLICY: 'resize-policy' +}); + +function immutableValue (value) { + if (Array.isArray(value)) return Object.freeze(value.map(immutableValue)); + if (value && Object.getPrototypeOf(value) === Object.prototype) { + return Object.freeze(Object.fromEntries( + Object.entries(value).map(([key, nested]) => [key, immutableValue(nested)]) + )); + } + return value; +} + +export function explicitProperty (value) { + return Object.freeze({ + kind: ComponentPropertyKind.EXPLICIT_VALUE, + value: immutableValue(value) + }); +} + +export function opaqueProperty (expression) { + if (typeof expression !== 'string' || !expression.trim()) { + throw new Error('Opaque component properties require a source expression'); + } + return Object.freeze({ + kind: ComponentPropertyKind.OPAQUE_EXPRESSION, + expression + }); +} + +export function localNodeProvenance () { + return Object.freeze({ kind: ComponentNodeProvenanceKind.LOCAL }); +} + +export function addedNodeProvenance ({ beforeId = null, beforeName = null } = {}) { + if (beforeId !== null && (typeof beforeId !== 'string' || !beforeId)) { + throw new Error('Added ordering references require a node ID'); + } + if (beforeName !== null && (typeof beforeName !== 'string' || !beforeName)) { + throw new Error('Added external ordering references require a node name'); + } + if (beforeId !== null && beforeName !== null) { + throw new Error('Added ordering references require either an ID or a name'); + } + return Object.freeze({ + kind: ComponentNodeProvenanceKind.ADDED, + beforeId, + beforeName + }); +} + +export function inheritedNodeProvenance ({ + suppressed = false, + hasLocalOverrides = false, + beforeId = null, + baseName = null +} = {}) { + if (beforeId !== null && (typeof beforeId !== 'string' || !beforeId)) { + throw new Error('Inherited ordering references require a node ID'); + } + if (baseName !== null && (typeof baseName !== 'string' || !baseName)) { + throw new Error('Inherited base names must be non-empty strings'); + } + return Object.freeze({ + kind: ComponentNodeProvenanceKind.INHERITED, + suppressed: !!suppressed, + hasLocalOverrides: !!hasLocalOverrides, + beforeId, + baseName + }); +} + +export function sourceComponentReference (expression) { + if (typeof expression !== 'string' || !expression.trim()) { + throw new Error('Component references require a source expression'); + } + return Object.freeze({ + kind: ComponentReferenceKind.SOURCE_EXPRESSION, + expression + }); +} + +export function resizePolicyLayoutReference ({ targetId, expressionTemplate }) { + if (typeof targetId !== 'string' || !targetId) { + throw new Error('Resize-policy layout references require a target node ID'); + } + if (typeof expressionTemplate !== 'string' || !expressionTemplate.trim()) { + throw new Error('Resize-policy layout references require an expression template'); + } + return Object.freeze({ + kind: ComponentLayoutReferenceKind.RESIZE_POLICY, + targetId, + expressionTemplate + }); +} + +export function tilingLayoutModel ({ ownerId, expressionTemplate, references = [] }) { + if (typeof ownerId !== 'string' || !ownerId) { + throw new Error('Component layout models require an owner node ID'); + } + if (typeof expressionTemplate !== 'string' || !expressionTemplate.trim()) { + throw new Error('Component layout models require an expression template'); + } + if (!Array.isArray(references)) { + throw new Error('Component layout model references must be an array'); + } + return Object.freeze({ + kind: ComponentLayoutKind.TILING, + ownerId, + expressionTemplate, + references: Object.freeze(references.map(reference => { + if (reference?.kind !== ComponentLayoutReferenceKind.RESIZE_POLICY) { + throw new Error('Invalid tiling-layout reference'); + } + return resizePolicyLayoutReference(reference); + })) + }); +} + +function normalizeLayoutModel (model) { + if (model?.kind === ComponentLayoutKind.TILING) return tilingLayoutModel(model); + throw new Error('Invalid component layout model'); +} + +function normalizeParentComponent (parentComponent) { + if (parentComponent === null) return null; + if (parentComponent?.kind === ComponentReferenceKind.SOURCE_EXPRESSION) { + return sourceComponentReference(parentComponent.expression); + } + throw new Error('Invalid parent component reference'); +} + +function normalizePropertyEntry (entry, property) { + if (entry?.kind === ComponentPropertyKind.EXPLICIT_VALUE && 'value' in entry) { + return explicitProperty(entry.value); + } + if (entry?.kind === ComponentPropertyKind.OPAQUE_EXPRESSION && + typeof entry.expression === 'string' && entry.expression.trim()) { + return opaqueProperty(entry.expression); + } + throw new Error(`Invalid component property entry for ${property}`); +} + +function normalizeProvenance (provenance) { + if (provenance?.kind === ComponentNodeProvenanceKind.LOCAL) { + return localNodeProvenance(); + } + if (provenance?.kind === ComponentNodeProvenanceKind.ADDED && + (provenance.beforeId === undefined || provenance.beforeId === null || + typeof provenance.beforeId === 'string') && + (provenance.beforeName === undefined || provenance.beforeName === null || + typeof provenance.beforeName === 'string')) { + return addedNodeProvenance({ + beforeId: provenance.beforeId || null, + beforeName: provenance.beforeName || null + }); + } + if (provenance?.kind === ComponentNodeProvenanceKind.INHERITED && + typeof provenance.suppressed === 'boolean' && + typeof provenance.hasLocalOverrides === 'boolean' && + (provenance.beforeId === null || typeof provenance.beforeId === 'string') && + (provenance.baseName === undefined || provenance.baseName === null || + typeof provenance.baseName === 'string')) { + return inheritedNodeProvenance(provenance); + } + throw new Error('Invalid component node provenance'); +} + +export class ComponentNode { + constructor ({ + id, + name, + provenance = localNodeProvenance(), + partComponent = null, + typeExpression = null, + properties = {}, + children = [] + }) { + if (typeof id !== 'string' || !id) throw new Error('Component nodes require a stable ID'); + if (typeof name !== 'string' || !name) throw new Error('Component nodes require a name'); + if (typeExpression !== null && typeof typeExpression !== 'string') { + throw new Error('Component node typeExpression must be a string or null'); + } + const normalizedProvenance = normalizeProvenance(provenance); + const normalizedPartComponent = normalizeParentComponent(partComponent); + const normalizedProperties = Object.fromEntries( + Object.entries(properties).map(([property, entry]) => + [property, normalizePropertyEntry(entry, property)]) + ); + if (children.some(child => !(child instanceof ComponentNode))) { + throw new Error('Component node children must be ComponentNode instances'); + } + this.id = id; + this.name = name; + this.provenance = normalizedProvenance; + this.partComponent = normalizedPartComponent; + this.typeExpression = typeExpression; + this.properties = Object.freeze(normalizedProperties); + this.children = Object.freeze(children.slice()); + Object.freeze(this); + } + + with (changes) { + return new ComponentNode({ + id: this.id, + name: this.name, + provenance: this.provenance, + partComponent: this.partComponent, + typeExpression: this.typeExpression, + properties: this.properties, + children: this.children, + ...changes + }); + } +} + +export class ComponentDocument { + constructor ({ + revision = 0, + componentId, + moduleId, + exportName, + parentComponent = null, + root, + layoutModels = [], + sourceMetadata = {} + }) { + if (!Number.isInteger(revision) || revision < 0) { + throw new Error('Component document revision must be a non-negative integer'); + } + if (typeof componentId !== 'string' || !componentId) { + throw new Error('Component documents require a componentId'); + } + if (typeof moduleId !== 'string' || !moduleId) { + throw new Error('Component documents require a moduleId'); + } + if (typeof exportName !== 'string' || !exportName) { + throw new Error('Component documents require an exportName'); + } + if (!(root instanceof ComponentNode)) { + throw new Error('Component documents require a root ComponentNode'); + } + if (!Array.isArray(layoutModels)) { + throw new Error('Component document layoutModels must be an array'); + } + this.revision = revision; + this.componentId = componentId; + this.moduleId = moduleId; + this.exportName = exportName; + this.parentComponent = normalizeParentComponent(parentComponent); + this.root = root; + this.layoutModels = Object.freeze(layoutModels.map(normalizeLayoutModel)); + this.sourceMetadata = immutableValue(sourceMetadata); + Object.freeze(this); + } + + withRoot (root) { + return new ComponentDocument({ + revision: this.revision + 1, + componentId: this.componentId, + moduleId: this.moduleId, + exportName: this.exportName, + parentComponent: this.parentComponent, + root, + layoutModels: this.layoutModels, + sourceMetadata: this.sourceMetadata + }); + } +} + +export function findComponentLayoutModel (document, ownerId) { + return document.layoutModels.find(model => model.ownerId === ownerId) || null; +} + +export function findComponentNode (document, nodeId) { + const visit = node => { + if (node.id === nodeId) return node; + for (const child of node.children) { + const found = visit(child); + if (found) return found; + } + return null; + }; + return visit(document.root); +} + +export function findComponentParent (document, nodeId) { + const visit = node => { + if (node.children.some(child => child.id === nodeId)) return node; + for (const child of node.children) { + const found = visit(child); + if (found) return found; + } + return null; + }; + return visit(document.root); +} diff --git a/lively.ide/components/reconciliation/component-projection-fuzzer.js b/lively.ide/components/reconciliation/component-projection-fuzzer.js new file mode 100644 index 0000000000..cd6b52ba77 --- /dev/null +++ b/lively.ide/components/reconciliation/component-projection-fuzzer.js @@ -0,0 +1,851 @@ +import { MorphicAttachmentKind } from 'lively.morphic/changes/index.js'; +import { + ClearPropertyOverride, + ComponentCommandKind, + ComponentTextEditKind, + EditText, + IntroduceNode, + MoveNode, + RemoveNode, + RenameNode, + RestoreInheritedNode, + SetMaster, + SetOpaqueProperty, + SetProperty, + SuppressInheritedNode +} from './commands.js'; +import { + ComponentNode, + ComponentNodeProvenanceKind, + ComponentPropertyKind, + explicitProperty, + findComponentLayoutModel, + findComponentParent, + localNodeProvenance +} from './component-document.js'; +import { + ComponentTransactionDirection, + applyPreparedComponentTransaction, + commitPreparedComponentTransaction, + prepareScalarComponentTransaction +} from './component-transaction.js'; +import { SeededRandom } from './fuzz-random.js'; +import { reduceComponent } from './reducer.js'; +import { parseComponentSource } from './source-adapter.js'; +import { + alignParsedDocumentIdentities, + componentDocumentsSemanticallyEqual +} from './source-projector.js'; +import { + ComponentImportKind, + componentImportBinding +} from './import-bindings.js'; + +export const DEFAULT_COMPONENT_PROJECTION_FUZZ_SEED = 0x51CA1A; + +export const ComponentProjectionFuzzOperationKind = Object.freeze({ + SET_PROPERTY: 'set-property', + SET_OPAQUE_PROPERTY: 'set-opaque-property', + CLEAR_PROPERTY_OVERRIDE: 'clear-property-override', + EDIT_TEXT: 'edit-text', + SET_MASTER: 'set-master', + RENAME_NODE: 'rename-node', + INTRODUCE_FINAL_NODE: 'introduce-final-node', + REMOVE_FINAL_NODE: 'remove-final-node', + REORDER_NODE: 'reorder-node', + REPARENT_NODE: 'reparent-node', + SUPPRESS_INHERITED_NODE: 'suppress-inherited-node', + RESTORE_INHERITED_NODE: 'restore-inherited-node', + REJECT_STALE_COMMAND: 'reject-stale-command' +}); + +export const DEFAULT_COMPONENT_PROJECTION_FUZZ_OPERATIONS = Object.freeze( + Object.values(ComponentProjectionFuzzOperationKind).filter(kind => ![ + ComponentProjectionFuzzOperationKind.SUPPRESS_INHERITED_NODE, + ComponentProjectionFuzzOperationKind.RESTORE_INHERITED_NODE + ].includes(kind)) +); + +export const defaultComponentProjectionFuzzSource = ` +const Subject = component({ + name: 'projection subject', + textAndAttributes: ['projection text', null], + master: { mode: 'base', priority: 0 }, + opacity: 0.8, + visible: true, + tooltip: 'root', + submorphs: [{ + name: 'first child', + opacity: 0.5, + fill: 'red' + }, { + name: 'second child', + visible: false, + data: { level: 1, enabled: true } + }] +}); + +export { Subject }; +`; + +const FUZZ_PROPERTIES = Object.freeze([ + 'opacity', + 'visible', + 'tooltip', + 'fill', + 'data', + 'padding' +]); + +const DEFAULT_BASELINE_VALUES = Object.freeze({ + opacity: 1, + visible: true, + tooltip: null, + fill: null, + data: null, + padding: 0 +}); + +function cloneValue (value) { + if (Array.isArray(value)) return value.map(cloneValue); + if (value && Object.getPrototypeOf(value) === Object.prototype) { + return Object.fromEntries( + Object.entries(value).map(([key, nested]) => [key, cloneValue(nested)]) + ); + } + return value; +} + +function allNodes (document) { + const nodes = []; + const visit = node => { + nodes.push(node); + node.children.forEach(visit); + }; + visit(document.root); + return nodes; +} + +function valuesEqual (left, right) { + return JSON.stringify(left) === JSON.stringify(right); +} + +function assertFuzzInvariant (condition, message) { + if (!condition) throw new Error(message); +} + +function runtimeSnapshot (runtimeTargets, runtimeParents, runtimeChildren) { + return Array.from(runtimeTargets.entries()) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([id, target]) => Object.freeze({ + id, + state: cloneValue(target), + parentId: runtimeParents.get(id), + index: runtimeParents.get(id) === null + ? null + : runtimeChildren.get(runtimeParents.get(id)).indexOf(id) + })); +} + +export class ComponentProjectionFuzzError extends Error { + constructor (message, details, cause) { + super(`${message}\nseed: ${details.seed}\nstep: ${details.step}\noperation: ${details.operation}`); + this.name = 'ComponentProjectionFuzzError'; + this.cause = cause; + Object.assign(this, details); + } +} + +export class ComponentProjectionFuzzer { + constructor ({ + source = defaultComponentProjectionFuzzSource, + moduleId = 'local://component-projection-fuzz/subject.cp.js', + exportName = 'Subject', + componentId = `${moduleId}#${exportName}`, + parentDocument = null, + resolveComponentDocument = null, + seed = DEFAULT_COMPONENT_PROJECTION_FUZZ_SEED, + operations = DEFAULT_COMPONENT_PROJECTION_FUZZ_OPERATIONS, + baselineValueFor = ({ property }) => cloneValue(DEFAULT_BASELINE_VALUES[property] ?? null) + } = {}) { + const parsed = parseComponentSource({ + source, moduleId, exportName, componentId, parentDocument, resolveComponentDocument + }); + if (!parsed.supported) { + throw new Error(`Component projection fuzz source is unsupported: ${JSON.stringify(parsed.diagnostics)}`); + } + if (typeof baselineValueFor !== 'function') { + throw new Error('Component projection fuzzing requires a baseline value resolver'); + } + const validOperations = new Set(Object.values(ComponentProjectionFuzzOperationKind)); + if (!operations.length || operations.some(operation => !validOperations.has(operation))) { + throw new Error('Component projection fuzzing requires known operation kinds'); + } + + this.source = source; + this.document = parsed.document; + this.parentDocument = parentDocument; + this.resolveComponentDocument = resolveComponentDocument; + this.seed = seed; + this.random = new SeededRandom(seed); + this.operations = operations.slice(); + this.operationQueue = []; + this.baselineValueFor = baselineValueFor; + this.actions = []; + this.nameCounter = 0; + this.runtimeTargets = new Map(); + this.runtimeTargetIds = new WeakMap(); + this.runtimeParents = new Map(); + this.runtimeChildren = new Map(); + this.opaqueRuntimeValues = new Map(); + + for (const node of allNodes(this.document)) { + const target = { name: node.name }; + for (const property of FUZZ_PROPERTIES) { + target[property] = this.baselineValue(node.id, property); + } + for (const [property, entry] of Object.entries(node.properties)) { + target[property] = entry.kind === ComponentPropertyKind.EXPLICIT_VALUE + ? cloneValue(entry.value) + : this.baselineValue(node.id, property); + } + this.runtimeTargets.set(node.id, target); + this.runtimeTargetIds.set(target, node.id); + this.runtimeChildren.set(node.id, node.children + .filter(child => child.provenance.kind !== ComponentNodeProvenanceKind.INHERITED || + !child.provenance.suppressed) + .map(child => child.id)); + } + const registerParents = (node, parentId = null) => { + this.runtimeParents.set(node.id, parentId); + node.children.forEach(child => registerParents( + child, + child.provenance.kind === ComponentNodeProvenanceKind.INHERITED && + child.provenance.suppressed + ? null + : node.id + )); + }; + registerParents(this.document.root); + } + + baselineValue (nodeId, property) { + return cloneValue(this.baselineValueFor({ nodeId, property, document: this.document })); + } + + valueForProperty (property) { + switch (property) { + case 'opacity': return this.random.integer(0, 11) / 10; + case 'visible': return this.random.boolean(); + case 'tooltip': return this.random.pick([null, '', `tip ${this.random.integer(0, 100)}`]); + case 'fill': return this.random.pick(['red', 'green', 'blue', null]); + case 'data': return { + level: this.random.integer(-5, 20), + enabled: this.random.boolean(), + tags: [this.random.integer(0, 5), this.random.boolean()] + }; + case 'padding': return [ + this.random.integer(0, 20), + this.random.integer(0, 20), + this.random.integer(0, 20), + this.random.integer(0, 20) + ]; + } + } + + nextOperation () { + if (!this.operationQueue.length) { + this.operationQueue = this.random.shuffle(this.operations); + } + return this.operationQueue.shift(); + } + + commandSpec (node) { + return { + componentId: this.document.componentId, + expectedRevision: this.document.revision, + nodeId: node.id + }; + } + + setPropertyAction () { + const node = this.random.pick(allNodes(this.document)); + const property = this.random.pick(FUZZ_PROPERTIES); + const value = this.valueForProperty(property); + return { + command: SetProperty({ ...this.commandSpec(node), property, value }), + runtimeValue: cloneValue(value), + action: { nodeId: node.id, property, value: cloneValue(value) } + }; + } + + setOpaquePropertyAction () { + const node = this.random.pick(allNodes(this.document)); + const property = this.random.pick(['opacity', 'padding', 'data']); + const left = this.random.integer(-20, 20); + const right = this.random.integer(-20, 20); + const requiresImport = this.random.boolean(); + const local = this.random.pick(['fuzzMax', 'projectionMax']); + const expression = requiresImport + ? `${local}(${left}, ${right})` + : `Math.max(${left}, ${right})`; + const requiredBindings = requiresImport + ? [componentImportBinding({ + kind: ComponentImportKind.NAMED, + moduleId: 'local://component-projection-fuzz/value-helpers.js', + imported: 'maxValue', + local + })] + : []; + return { + command: SetOpaqueProperty({ + ...this.commandSpec(node), + property, + expression, + requiredBindings + }), + runtimeValue: Math.max(left, right), + action: { + nodeId: node.id, + property, + expression, + requiredBindings, + runtimeValue: Math.max(left, right) + } + }; + } + + clearPropertyAction () { + const candidates = allNodes(this.document).flatMap(node => + Object.keys(node.properties) + .filter(property => property !== 'textAndAttributes') + .map(property => ({ node, property })) + ); + const candidate = this.random.pick(candidates); + if (!candidate) return null; + const { node, property } = candidate; + return { + command: ClearPropertyOverride({ ...this.commandSpec(node), property }), + runtimeValue: this.baselineValue(node.id, property), + action: { nodeId: node.id, property } + }; + } + + editTextAction () { + const candidates = allNodes(this.document).filter(node => + node.properties.textAndAttributes?.kind === ComponentPropertyKind.EXPLICIT_VALUE + ); + const node = this.random.pick(candidates); + if (!node) return null; + const before = node.properties.textAndAttributes.value; + const after = [ + `projection text ${this.random.integer(0, 1000)}`, + this.random.boolean() ? null : { fontWeight: 'bold' } + ]; + return { + command: EditText({ + ...this.commandSpec(node), + operation: { + kind: ComponentTextEditKind.REPLACE_ALL, + before, + after + } + }), + runtimeValue: cloneValue(after), + action: { nodeId: node.id, before: cloneValue(before), after: cloneValue(after) } + }; + } + + setMasterAction () { + const node = this.random.pick(allNodes(this.document)); + const value = { + mode: this.random.pick(['base', 'hover', 'active']), + priority: this.random.integer(0, 10) + }; + return { + command: SetMaster({ ...this.commandSpec(node), value }), + runtimeValue: cloneValue(value), + action: { nodeId: node.id, value: cloneValue(value) } + }; + } + + renameNodeAction () { + const node = this.random.pick(allNodes(this.document)); + const suffix = this.random.pick(['', " 'quoted'", ' "double"', ' \\backslash']); + const name = `projection node ${++this.nameCounter}${suffix}`; + return { + command: RenameNode({ ...this.commandSpec(node), name }), + runtimeValue: name, + action: { nodeId: node.id, name } + }; + } + + removeFinalNodeAction () { + const candidates = allNodes(this.document).flatMap(parent => { + const node = parent.children[parent.children.length - 1]; + return node ? [{ parent, node }] : []; + }); + const candidate = this.random.pick(candidates); + if (!candidate) return null; + return { + command: RemoveNode(this.commandSpec(candidate.node)), + action: { nodeId: candidate.node.id, parentId: candidate.parent.id } + }; + } + + componentNodeForDetachedRuntime (nodeId) { + const target = this.runtimeTargets.get(nodeId); + if (!target) return null; + const properties = Object.fromEntries( + Object.entries(target) + .filter(([property]) => property !== 'name') + .map(([property, value]) => [property, explicitProperty(value)]) + ); + const children = (this.runtimeChildren.get(nodeId) || []) + .map(childId => this.componentNodeForDetachedRuntime(childId)); + if (children.some(child => !child)) return null; + return new ComponentNode({ + id: nodeId, + name: target.name, + provenance: localNodeProvenance(), + properties, + children + }); + } + + introduceFinalNodeAction () { + const parent = this.document.root; + let nodeOrdinal = parent.children.length; + let nodeId = `${this.document.componentId}:node:${nodeOrdinal}`; + let target = this.runtimeTargets.get(nodeId); + while (target && this.runtimeParents.get(nodeId) !== null) { + nodeId = `${this.document.componentId}:node:${++nodeOrdinal}`; + target = this.runtimeTargets.get(nodeId); + } + if (!target) { + target = { name: `introduced fuzz node ${++this.nameCounter}` }; + for (const property of FUZZ_PROPERTIES) { + target[property] = this.baselineValue(nodeId, property); + } + target.fill = this.valueForProperty('fill'); + this.runtimeTargets.set(nodeId, target); + this.runtimeTargetIds.set(target, nodeId); + this.runtimeParents.set(nodeId, null); + this.runtimeChildren.set(nodeId, []); + } + const node = this.componentNodeForDetachedRuntime(nodeId); + if (!node) return null; + return { + command: IntroduceNode({ + ...this.commandSpec(node), + parentId: parent.id, + beforeId: null, + node + }), + action: { nodeId, parentId: parent.id, name: node.name } + }; + } + + reorderNodeAction () { + const parent = this.random.pick( + allNodes(this.document).filter(node => node.children.length > 1) + ); + if (!parent) return null; + const fromIndex = this.random.integer(0, parent.children.length); + const toIndex = this.random.pick( + parent.children.map((_child, index) => index).filter(index => index !== fromIndex) + ); + const node = parent.children[fromIndex]; + const siblings = parent.children.filter(child => child !== node); + return { + command: MoveNode({ + ...this.commandSpec(node), + parentId: parent.id, + beforeId: siblings[toIndex]?.id ?? null + }), + action: { nodeId: node.id, parentId: parent.id, fromIndex, toIndex } + }; + } + + reparentNodeAction () { + const nodes = allNodes(this.document); + const candidates = nodes.slice(1).flatMap(node => { + const previousParent = findComponentParent(this.document, node.id); + const subtreeIds = new Set(allNodes({ root: node }).map(descendant => descendant.id)); + return nodes + .filter(parent => parent !== previousParent && !subtreeIds.has(parent.id) && + !parent.children.some(child => child.name === node.name)) + .map(parent => ({ node, previousParent, parent })); + }); + const candidate = this.random.pick(candidates); + if (!candidate) return null; + const { node, previousParent, parent } = candidate; + const toIndex = this.random.integer(0, parent.children.length + 1); + return { + command: MoveNode({ + ...this.commandSpec(node), + parentId: parent.id, + beforeId: parent.children[toIndex]?.id ?? null + }), + action: { + nodeId: node.id, + previousParentId: previousParent.id, + parentId: parent.id, + toIndex + } + }; + } + + suppressInheritedNodeAction () { + const node = this.random.pick(allNodes(this.document).filter(candidate => + candidate.provenance.kind === ComponentNodeProvenanceKind.INHERITED && + !candidate.provenance.suppressed)); + if (!node) return null; + return { + command: SuppressInheritedNode(this.commandSpec(node)), + action: { nodeId: node.id } + }; + } + + restoreInheritedNodeAction () { + const node = this.random.pick(allNodes(this.document).filter(candidate => + candidate.provenance.kind === ComponentNodeProvenanceKind.INHERITED && + candidate.provenance.suppressed)); + if (!node) return null; + const parent = findComponentParent(this.document, node.id); + return { + command: RestoreInheritedNode({ + ...this.commandSpec(node), + parentId: parent.id, + beforeId: node.provenance.beforeId + }), + action: { nodeId: node.id, parentId: parent.id } + }; + } + + staleCommandAction () { + const node = this.random.pick(allNodes(this.document)); + const property = this.random.pick(FUZZ_PROPERTIES); + const command = SetProperty({ + ...this.commandSpec(node), + expectedRevision: this.document.revision + 1, + property, + value: this.valueForProperty(property) + }); + return { command, rejected: true, action: { nodeId: node.id, property } }; + } + + actionFor (operation) { + switch (operation) { + case ComponentProjectionFuzzOperationKind.SET_PROPERTY: + return this.setPropertyAction(); + case ComponentProjectionFuzzOperationKind.SET_OPAQUE_PROPERTY: + return this.setOpaquePropertyAction(); + case ComponentProjectionFuzzOperationKind.CLEAR_PROPERTY_OVERRIDE: + return this.clearPropertyAction(); + case ComponentProjectionFuzzOperationKind.EDIT_TEXT: + return this.editTextAction(); + case ComponentProjectionFuzzOperationKind.SET_MASTER: + return this.setMasterAction(); + case ComponentProjectionFuzzOperationKind.RENAME_NODE: + return this.renameNodeAction(); + case ComponentProjectionFuzzOperationKind.INTRODUCE_FINAL_NODE: + return this.introduceFinalNodeAction(); + case ComponentProjectionFuzzOperationKind.REMOVE_FINAL_NODE: + return this.removeFinalNodeAction(); + case ComponentProjectionFuzzOperationKind.REORDER_NODE: + return this.reorderNodeAction(); + case ComponentProjectionFuzzOperationKind.REPARENT_NODE: + return this.reparentNodeAction(); + case ComponentProjectionFuzzOperationKind.SUPPRESS_INHERITED_NODE: + return this.suppressInheritedNodeAction(); + case ComponentProjectionFuzzOperationKind.RESTORE_INHERITED_NODE: + return this.restoreInheritedNodeAction(); + case ComponentProjectionFuzzOperationKind.REJECT_STALE_COMMAND: + return this.staleCommandAction(); + } + } + + chooseAction () { + const attempted = new Set(); + while (attempted.size < this.operations.length) { + const operation = this.nextOperation(); + if (attempted.has(operation)) continue; + attempted.add(operation); + const selected = this.actionFor(operation); + if (selected) return { operation, ...selected }; + } + throw new Error('No component projection fuzz operation is currently applicable'); + } + + runtimeContext () { + return { + resolveMorph: nodeId => this.runtimeTargets.get(nodeId), + readMorphProperty: (target, property) => target[property], + setMorphProperty: (target, property, value) => { target[property] = cloneValue(value); }, + validateMoveMorph: (target, from) => { + const nodeId = this.runtimeTargetIds.get(target); + const parentId = this.runtimeParents.get(nodeId); + if (from.kind === MorphicAttachmentKind.DETACHED) { + assertFuzzInvariant(parentId === null, `Runtime node ${nodeId} is not detached`); + return; + } + assertFuzzInvariant(parentId === from.ownerId, `Runtime parent diverged for ${nodeId}`); + assertFuzzInvariant( + this.runtimeChildren.get(parentId)[from.index] === nodeId, + `Runtime index diverged for ${nodeId}` + ); + }, + moveMorph: (target, from, to) => { + const nodeId = this.runtimeTargetIds.get(target); + if (from.kind === MorphicAttachmentKind.ATTACHED) { + this.runtimeChildren.get(from.ownerId).splice(from.index, 1); + this.runtimeParents.set(nodeId, null); + } + if (to.kind === MorphicAttachmentKind.ATTACHED) { + this.runtimeChildren.get(to.ownerId).splice(to.index, 0, nodeId); + this.runtimeParents.set(nodeId, to.ownerId); + } + } + }; + } + + runtimeValueResolver (selected) { + return ({ phase, nodeId, property }) => Object.freeze({ + available: true, + value: phase === 'before' + ? cloneValue(this.runtimeTargets.get(nodeId)?.[property]) + : cloneValue(selected.runtimeValue) + }); + } + + adapters () { + return { + sourceStore: { + read: () => this.source, + write: source => { this.source = source; } + }, + documentStore: { + read: () => this.document, + write: document => { this.document = document; } + }, + runtimeContext: this.runtimeContext() + }; + } + + assertProjectionAgreement () { + const parsed = parseComponentSource({ + source: this.source, + moduleId: this.document.moduleId, + exportName: this.document.exportName, + componentId: this.document.componentId, + parentDocument: this.parentDocument, + resolveComponentDocument: this.resolveComponentDocument + }); + assertFuzzInvariant(parsed.supported, 'Projected source stopped parsing'); + assertFuzzInvariant( + componentDocumentsSemanticallyEqual( + alignParsedDocumentIdentities(parsed.document, this.document), + this.document + ), + 'Projected source diverged from the reducer document' + ); + assertFuzzInvariant( + this.runtimeParents.get(this.document.root.id) === null, + 'Runtime root unexpectedly acquired a parent' + ); + for (const node of allNodes(this.document)) { + const target = this.runtimeTargets.get(node.id); + assertFuzzInvariant(target?.name === node.name, `Runtime name diverged for ${node.id}`); + assertFuzzInvariant( + valuesEqual( + this.runtimeChildren.get(node.id), + node.children + .filter(child => child.provenance.kind !== ComponentNodeProvenanceKind.INHERITED || + !child.provenance.suppressed) + .map(child => child.id) + ), + `Runtime child order diverged for ${node.id}` + ); + node.children.forEach(child => { + const expectedParentId = child.provenance.kind === ComponentNodeProvenanceKind.INHERITED && + child.provenance.suppressed + ? null + : node.id; + assertFuzzInvariant( + this.runtimeParents.get(child.id) === expectedParentId, + `Runtime parent diverged for ${child.id}` + ); + }); + const properties = new Set([ + ...FUZZ_PROPERTIES, + ...Object.keys(target), + ...Object.keys(node.properties) + ]); + properties.delete('name'); + for (const property of properties) { + if (property === 'layout' && findComponentLayoutModel(this.document, node.id)) { + continue; + } + const entry = node.properties[property]; + const key = `${node.id}:${property}`; + const expected = entry?.kind === ComponentPropertyKind.EXPLICIT_VALUE + ? entry.value + : entry?.kind === ComponentPropertyKind.OPAQUE_EXPRESSION + ? this.opaqueRuntimeValues.get(key) + : this.baselineValue(node.id, property); + assertFuzzInvariant( + valuesEqual(target[property], expected), + `Runtime value diverged for ${node.id}.${property}` + ); + } + } + } + + performRejectedAction (selected) { + const sourceBefore = this.source; + const documentBefore = this.document; + const runtimeBefore = runtimeSnapshot( + this.runtimeTargets, + this.runtimeParents, + this.runtimeChildren + ); + const planned = prepareScalarComponentTransaction({ + id: `fuzz-${this.seed}-${this.actions.length}`, + source: this.source, + document: this.document, + command: selected.command, + resolveRuntimeTargetId: nodeId => nodeId, + resolveRuntimeValue: this.runtimeValueResolver(selected) + }); + assertFuzzInvariant(!planned.supported, 'A stale component command was unexpectedly planned'); + assertFuzzInvariant(this.source === sourceBefore, 'Rejected planning changed source'); + assertFuzzInvariant(this.document === documentBefore, 'Rejected planning changed the document'); + assertFuzzInvariant( + valuesEqual(runtimeSnapshot( + this.runtimeTargets, + this.runtimeParents, + this.runtimeChildren + ), runtimeBefore), + 'Rejected planning changed runtime state' + ); + } + + performCommandAction (selected) { + const sourceBefore = this.source; + const documentBefore = this.document; + const runtimeBefore = runtimeSnapshot( + this.runtimeTargets, + this.runtimeParents, + this.runtimeChildren + ); + const planned = prepareScalarComponentTransaction({ + id: `fuzz-${this.seed}-${this.actions.length}`, + source: this.source, + document: this.document, + command: selected.command, + resolveRuntimeTargetId: nodeId => nodeId, + resolveRuntimeValue: this.runtimeValueResolver(selected) + }); + assertFuzzInvariant( + planned.supported, + `Component command planning failed: ${JSON.stringify(planned.diagnostics)}` + ); + + const inverseReduction = reduceComponent( + planned.transaction.document, + planned.transaction.inverseCommand + ); + assertFuzzInvariant( + componentDocumentsSemanticallyEqual(inverseReduction.document, documentBefore), + 'A semantic command followed by its inverse did not restore the document' + ); + + commitPreparedComponentTransaction(planned.transaction, this.adapters()); + applyPreparedComponentTransaction(planned.transaction, { + ...this.adapters(), + direction: ComponentTransactionDirection.REVERSE + }); + assertFuzzInvariant(this.source === sourceBefore, 'Transaction undo did not restore exact source'); + assertFuzzInvariant(this.document === documentBefore, 'Transaction undo did not restore the document snapshot'); + assertFuzzInvariant( + valuesEqual(runtimeSnapshot( + this.runtimeTargets, + this.runtimeParents, + this.runtimeChildren + ), runtimeBefore), + 'Transaction undo did not restore runtime state' + ); + applyPreparedComponentTransaction(planned.transaction, { + ...this.adapters(), + direction: ComponentTransactionDirection.FORWARD + }); + + const key = `${selected.command.nodeId}:${selected.command.property}`; + if (selected.command.kind === ComponentCommandKind.SET_PROPERTY && + selected.command.entry.kind === ComponentPropertyKind.OPAQUE_EXPRESSION) { + this.opaqueRuntimeValues.set(key, cloneValue(selected.runtimeValue)); + } else if (selected.command.property) { + this.opaqueRuntimeValues.delete(key); + } + this.assertProjectionAgreement(); + } + + step () { + const step = this.actions.length; + let selected = { operation: 'select-operation', action: {} }; + try { + selected = this.chooseAction(); + if (selected.rejected) this.performRejectedAction(selected); + else this.performCommandAction(selected); + const recorded = Object.freeze({ + step, + operation: selected.operation, + ...selected.action, + revision: this.document.revision, + sourceLength: this.source.length + }); + this.actions.push(recorded); + return recorded; + } catch (error) { + throw new ComponentProjectionFuzzError( + `Component projection fuzzing failed: ${error.message}`, + { + seed: this.seed, + step, + operation: selected.operation, + action: selected.action, + actions: [...this.actions], + source: this.source, + layoutModels: this.document.layoutModels, + layoutReferenceLocations: this.document.sourceMetadata.layoutReferenceLocations + }, + error + ); + } + } + + run (steps = 100) { + if (!Number.isInteger(steps) || steps < 0) { + throw new Error(`Invalid component projection fuzz step count: ${steps}`); + } + while (this.actions.length < steps) this.step(); + return Object.freeze({ + seed: this.seed, + steps, + actions: Object.freeze(this.actions.slice()), + source: this.source, + document: this.document, + runtime: Object.freeze(runtimeSnapshot( + this.runtimeTargets, + this.runtimeParents, + this.runtimeChildren + )) + }); + } +} + +export function runComponentProjectionFuzz (options = {}) { + const { steps = 100, ...fuzzerOptions } = options; + return new ComponentProjectionFuzzer(fuzzerOptions).run(steps); +} diff --git a/lively.ide/components/reconciliation/component-transaction.js b/lively.ide/components/reconciliation/component-transaction.js new file mode 100644 index 0000000000..c43e200680 --- /dev/null +++ b/lively.ide/components/reconciliation/component-transaction.js @@ -0,0 +1,529 @@ +import { + MorphicChangeSet, + MorphicValueSemantics +} from 'lively.morphic/changes/index.js'; +import { EditTransaction, EditTransactionKind } from 'lively.morphic/undo.js'; +import { ComponentDocument } from './component-document.js'; +import { reduceComponent } from './reducer.js'; +import { projectComponentRuntime } from './runtime-projector.js'; +import { projectComponentSource } from './source-projector.js'; + +export const ComponentTransactionPlanningDiagnosticKind = Object.freeze({ + REDUCTION_FAILED: 'reduction-failed', + SOURCE_PROJECTION_FAILED: 'source-projection-failed', + RUNTIME_PROJECTION_FAILED: 'runtime-projection-failed' +}); + +export const ComponentTransactionState = Object.freeze({ + COMMITTED: 'committed' +}); + +export const ComponentRuntimeCommitMode = Object.freeze({ + APPLY: 'apply', + ADOPT_ALREADY_APPLIED: 'adopt-already-applied' +}); + +export const ComponentTransactionDirection = Object.freeze({ + FORWARD: 'forward', + REVERSE: 'reverse' +}); + +const runtimeCommitModes = new Set(Object.values(ComponentRuntimeCommitMode)); +const transactionDirections = new Set(Object.values(ComponentTransactionDirection)); + +function diagnostic (kind, message, details = {}) { + return Object.freeze({ kind, message, ...details }); +} + +function planningFailure (diagnostics) { + return Object.freeze({ + supported: false, + transaction: null, + diagnostics: Object.freeze(diagnostics) + }); +} + +function isPromise (value) { + return value && typeof value.then === 'function'; +} + +function assertStore (store, label) { + if (!store || typeof store.read !== 'function' || typeof store.write !== 'function') { + throw new Error(`Component transactions require a ${label} store`); + } +} + +function writeStore (store, value, transaction, label) { + const result = store.write(value, transaction); + if (isPromise(result)) { + throw new Error(`Component transaction ${label} stores must be synchronous`); + } + if (store.read() !== value) { + throw new Error(`Component transaction ${label} store diverged from the planned value`); + } +} + +export class PreparedComponentTransaction { + constructor ({ + id, + sourceBefore, + sourceAfter, + beforeDocument, + document, + command, + inverseCommand, + reduction, + sourceProjection, + runtimeProjection, + steps = null + }) { + if (typeof id !== 'string' || !id) { + throw new Error('Prepared component transactions require an id'); + } + if (typeof sourceBefore !== 'string' || typeof sourceAfter !== 'string') { + throw new Error('Prepared component transactions require source snapshots'); + } + if (!(beforeDocument instanceof ComponentDocument) || + !(document instanceof ComponentDocument)) { + throw new Error('Prepared component transactions require document snapshots'); + } + const normalizedSteps = steps || [{ + componentCommand: command, + reduction, + sourceProjection, + runtimeProjection + }]; + if (!Array.isArray(normalizedSteps) || !normalizedSteps.length) { + throw new Error('Prepared component transactions require at least one command step'); + } + let expectedSource = sourceBefore; + for (const step of normalizedSteps) { + if (!step.componentCommand || !step.reduction?.inverseCommand) { + throw new Error('Prepared component transaction steps require exact inverse commands'); + } + if (step.sourceProjection?.supported !== true || + step.sourceProjection.sourceBefore !== expectedSource || + !(step.sourceProjection.projectedDocument instanceof ComponentDocument)) { + throw new Error('Prepared component transaction steps must form a continuous source plan'); + } + if (step.runtimeProjection?.supported !== true || + !(step.runtimeProjection.changeSet instanceof MorphicChangeSet) || + !(step.runtimeProjection.inverseChangeSet instanceof MorphicChangeSet)) { + throw new Error('Prepared component transaction steps require reversible runtime projections'); + } + expectedSource = step.sourceProjection.sourceAfter; + } + if (expectedSource !== sourceAfter || + normalizedSteps[normalizedSteps.length - 1] + .sourceProjection.projectedDocument !== document) { + throw new Error('Prepared component transaction steps do not reach the planned result'); + } + if (sourceProjection?.supported !== true || + sourceProjection.sourceBefore !== sourceBefore || + sourceProjection.sourceAfter !== sourceAfter || + sourceProjection.projectedDocument !== document) { + throw new Error('Prepared component transactions require a complete source projection'); + } + if (runtimeProjection?.supported !== true) { + throw new Error('Prepared component transactions require a supported runtime projection'); + } + if (!(runtimeProjection?.changeSet instanceof MorphicChangeSet) || + !(runtimeProjection?.inverseChangeSet instanceof MorphicChangeSet)) { + throw new Error('Prepared component transactions require reversible runtime projections'); + } + this.id = id; + this.sourceBefore = sourceBefore; + this.sourceAfter = sourceAfter; + this.beforeDocument = beforeDocument; + this.document = document; + this.steps = Object.freeze(normalizedSteps.slice()); + this.commands = Object.freeze(normalizedSteps.map(step => step.componentCommand)); + this.inverseCommands = Object.freeze(normalizedSteps.slice().reverse() + .map(step => step.reduction.inverseCommand)); + this.command = this.commands.length === 1 ? this.commands[0] : null; + this.inverseCommand = this.inverseCommands.length === 1 ? this.inverseCommands[0] : null; + this.reduction = this.steps.length === 1 ? this.steps[0].reduction : null; + this.sourceProjection = sourceProjection; + this.runtimeProjection = runtimeProjection; + this.runtimeChangeSet = runtimeProjection.changeSet; + this.inverseRuntimeChangeSet = runtimeProjection.inverseChangeSet; + Object.freeze(this); + } +} + +export class ComponentTransactionConflictError extends Error { + constructor (message, transaction) { + super(message); + this.name = 'ComponentTransactionConflictError'; + this.transaction = transaction; + } +} + +export class ComponentTransactionRollbackError extends Error { + constructor (message, cause, rollbackErrors, transaction) { + super(message); + this.name = 'ComponentTransactionRollbackError'; + this.cause = cause; + this.rollbackErrors = Object.freeze(rollbackErrors.slice()); + this.transaction = transaction; + } +} + +export function preparedComponentTransactionFromScalarShadowProjection ({ + id, + shadowProjection +}) { + if (!shadowProjection?.supported || shadowProjection.steps?.length !== 1) { + throw new Error('Projectional cutover requires one supported shadow projection step'); + } + const [step] = shadowProjection.steps; + return new PreparedComponentTransaction({ + id, + sourceBefore: shadowProjection.sourceBefore, + sourceAfter: shadowProjection.sourceAfter, + beforeDocument: shadowProjection.beforeDocument, + document: shadowProjection.document, + command: step.componentCommand, + inverseCommand: step.reduction.inverseCommand, + reduction: step.reduction, + sourceProjection: step.sourceProjection, + runtimeProjection: step.runtimeProjection + }); +} + +export function preparedComponentTransactionFromShadowProjection ({ id, shadowProjection }) { + if (!shadowProjection?.supported || !shadowProjection.steps?.length) { + throw new Error('Projectional cutover requires a supported shadow projection'); + } + if (shadowProjection.steps.length === 1) { + return preparedComponentTransactionFromScalarShadowProjection({ id, shadowProjection }); + } + return new PreparedComponentTransaction({ + id, + sourceBefore: shadowProjection.sourceBefore, + sourceAfter: shadowProjection.sourceAfter, + beforeDocument: shadowProjection.beforeDocument, + document: shadowProjection.document, + sourceProjection: Object.freeze({ + supported: true, + sourceBefore: shadowProjection.sourceBefore, + sourceAfter: shadowProjection.sourceAfter, + projectedDocument: shadowProjection.document + }), + runtimeProjection: Object.freeze({ + supported: true, + changeSet: shadowProjection.runtimeChangeSet, + inverseChangeSet: shadowProjection.inverseRuntimeChangeSet + }), + steps: shadowProjection.steps + }); +} + +export function prepareScalarComponentTransaction ({ + id, + source, + document, + command, + resolveRuntimeTargetId, + resolveRuntimeValue, + resolveRuntimeLayout +}) { + if (typeof id !== 'string' || !id) { + throw new Error('Component transaction planning requires an id'); + } + if (typeof source !== 'string') { + throw new Error('Component transaction planning requires source text'); + } + if (!(document instanceof ComponentDocument)) { + throw new Error('Component transaction planning requires a ComponentDocument'); + } + + let reduction; + try { + reduction = reduceComponent(document, command); + } catch (error) { + return planningFailure([diagnostic( + ComponentTransactionPlanningDiagnosticKind.REDUCTION_FAILED, + error.message, + { error, command } + )]); + } + + const sourceProjection = projectComponentSource({ + source, + beforeDocument: document, + reduction + }); + if (!sourceProjection.supported) { + return planningFailure([diagnostic( + ComponentTransactionPlanningDiagnosticKind.SOURCE_PROJECTION_FAILED, + 'The component command could not be projected into source', + { sourceDiagnostics: sourceProjection.diagnostics, command } + )]); + } + + const runtimeProjection = projectComponentRuntime({ + beforeDocument: document, + reduction, + changeSetId: `${id}:runtime`, + resolveRuntimeTargetId, + resolveRuntimeValue, + resolveRuntimeLayout + }); + if (!runtimeProjection.supported) { + return planningFailure([diagnostic( + ComponentTransactionPlanningDiagnosticKind.RUNTIME_PROJECTION_FAILED, + 'The component command could not be projected into runtime operations', + { runtimeDiagnostics: runtimeProjection.diagnostics, command } + )]); + } + + return Object.freeze({ + supported: true, + transaction: new PreparedComponentTransaction({ + id, + sourceBefore: source, + sourceAfter: sourceProjection.sourceAfter, + beforeDocument: document, + document: sourceProjection.projectedDocument, + command, + inverseCommand: reduction.inverseCommand, + reduction, + sourceProjection, + runtimeProjection + }), + diagnostics: Object.freeze([]) + }); +} + +function transactionReplay (transaction, direction) { + const forward = direction === ComponentTransactionDirection.FORWARD; + return Object.freeze({ + sourceBefore: forward ? transaction.sourceBefore : transaction.sourceAfter, + sourceAfter: forward ? transaction.sourceAfter : transaction.sourceBefore, + documentBefore: forward ? transaction.beforeDocument : transaction.document, + documentAfter: forward ? transaction.document : transaction.beforeDocument, + runtimeChangeSet: forward + ? transaction.runtimeChangeSet + : transaction.inverseRuntimeChangeSet, + inverseRuntimeChangeSet: forward + ? transaction.inverseRuntimeChangeSet + : transaction.runtimeChangeSet + }); +} + +function supplementalAdoptionChangeSet (changeSet, transactionId) { + const operations = changeSet.operations.filter(operation => + operation.metadata.applyWhenAdopting === true); + return operations.length + ? new MorphicChangeSet({ + id: `${transactionId}:adoption-supplement`, + label: 'apply supplemental adopted runtime changes', + origin: 'runtime-projection', + undoable: false, + operations, + metadata: { supplementalFor: transactionId } + }) + : null; +} + +function supplementalRuntimeStateIsCurrent (changeSet, runtimeContext) { + return changeSet.operations.every(operation => { + const target = runtimeContext.resolveMorph?.(operation.targetId); + if (!target || !Object.prototype.hasOwnProperty.call(operation, 'property')) { + return false; + } + const current = runtimeContext.readMorphProperty + ? runtimeContext.readMorphProperty(target, operation.property) + : target[operation.property]; + return operation.valueSemantics === MorphicValueSemantics.SNAPSHOT + ? operation.snapshotValuesEqual(operation.snapshotValue(current), operation.after) + : Object.is(current, operation.after); + }); +} + +export function applyPreparedComponentTransaction (transaction, { + sourceStore, + documentStore, + runtimeContext, + runtimeCommitMode = ComponentRuntimeCommitMode.APPLY, + direction = ComponentTransactionDirection.FORWARD +}) { + if (!(transaction instanceof PreparedComponentTransaction)) { + throw new Error('Can only commit a PreparedComponentTransaction'); + } + assertStore(sourceStore, 'source'); + assertStore(documentStore, 'document'); + if (!runtimeCommitModes.has(runtimeCommitMode)) { + throw new Error(`Unknown component runtime commit mode: ${runtimeCommitMode}`); + } + if (!transactionDirections.has(direction)) { + throw new Error(`Unknown component transaction direction: ${direction}`); + } + if (runtimeCommitMode === ComponentRuntimeCommitMode.ADOPT_ALREADY_APPLIED && + direction !== ComponentTransactionDirection.FORWARD) { + throw new Error('Already-applied runtime changes can only be adopted forward'); + } + const replay = transactionReplay(transaction, direction); + if (sourceStore.read() !== replay.sourceBefore) { + throw new ComponentTransactionConflictError( + `Source changed while component transaction ${transaction.id} was being planned`, + transaction + ); + } + if (documentStore.read() !== replay.documentBefore) { + throw new ComponentTransactionConflictError( + `Component document changed while transaction ${transaction.id} was being planned`, + transaction + ); + } + + // Runtime validation belongs to planning/validation and must happen before + // either authoritative representation is mutated. A direct mutation can + // leave explicitly marked supplemental operations unapplied; install those + // first, then validate that the complete runtime result can be adopted. + const supplementalChangeSet = runtimeCommitMode === + ComponentRuntimeCommitMode.ADOPT_ALREADY_APPLIED + ? supplementalAdoptionChangeSet(replay.runtimeChangeSet, transaction.id) + : null; + const runtimeValidationSet = runtimeCommitMode === ComponentRuntimeCommitMode.APPLY + ? replay.runtimeChangeSet + : replay.inverseRuntimeChangeSet; + try { + supplementalChangeSet?.apply(runtimeContext); + runtimeValidationSet.validate(runtimeContext); + } catch (error) { + if (supplementalChangeSet) { + try { + supplementalChangeSet.invert({ + id: `${supplementalChangeSet.id}:rollback`, + origin: 'runtime-projection' + }).apply(runtimeContext); + } catch (rollbackError) { + error.rollbackErrors = [...(error.rollbackErrors || []), rollbackError]; + } + } + throw error; + } + + let sourceAttempted = false; + let documentAttempted = false; + try { + sourceAttempted = true; + writeStore(sourceStore, replay.sourceAfter, transaction, 'source'); + documentAttempted = true; + writeStore(documentStore, replay.documentAfter, transaction, 'document'); + if (runtimeCommitMode === ComponentRuntimeCommitMode.APPLY) { + replay.runtimeChangeSet.apply(runtimeContext); + } else if (supplementalChangeSet && + !supplementalRuntimeStateIsCurrent(supplementalChangeSet, runtimeContext)) { + // Installing new source can synchronously refresh a live component from + // its policy cache. Reassert supplemental layout/name projections after + // that refresh so adoption ends in the state that was preflighted. + supplementalChangeSet.apply({ + ...runtimeContext, + checkPreconditions: false + }); + } + } catch (error) { + const rollbackErrors = error.rollbackErrors?.slice() || []; + if (documentAttempted) { + try { + writeStore(documentStore, replay.documentBefore, transaction, 'document'); + } catch (rollbackError) { + rollbackErrors.push(rollbackError); + } + } + if (sourceAttempted) { + try { + writeStore(sourceStore, replay.sourceBefore, transaction, 'source'); + } catch (rollbackError) { + rollbackErrors.push(rollbackError); + } + } + if (runtimeCommitMode === ComponentRuntimeCommitMode.ADOPT_ALREADY_APPLIED) { + try { + replay.inverseRuntimeChangeSet.apply(runtimeContext); + } catch (rollbackError) { + rollbackErrors.push(rollbackError); + } + } + if (rollbackErrors.length) { + throw new ComponentTransactionRollbackError( + `Failed to commit component transaction ${transaction.id} and roll it back completely`, + error, + rollbackErrors, + transaction + ); + } + throw error; + } + + return Object.freeze({ + state: ComponentTransactionState.COMMITTED, + transaction, + runtimeCommitMode, + direction + }); +} + +export function commitPreparedComponentTransaction (transaction, adapters) { + return applyPreparedComponentTransaction(transaction, { + ...adapters, + direction: ComponentTransactionDirection.FORWARD + }); +} + +export class ProjectionalComponentEditTransaction extends EditTransaction { + constructor (transaction, adapters, { + label = transaction?.commands?.length > 1 + ? 'component command batch' + : `component ${transaction?.command?.kind || 'command'}`, + afterReplay = null + } = {}) { + if (!(transaction instanceof PreparedComponentTransaction)) { + throw new Error('ProjectionalComponentEditTransaction requires a prepared transaction'); + } + assertStore(adapters?.sourceStore, 'source'); + assertStore(adapters?.documentStore, 'document'); + if (afterReplay !== null && typeof afterReplay !== 'function') { + throw new Error('Projectional component replay notifications must be functions'); + } + super({ + kind: EditTransactionKind.COMPONENT_COMMAND, + label, + metadata: { componentTransactionId: transaction.id } + }); + this.transaction = transaction; + this.adapters = adapters; + this.afterReplay = afterReplay; + this.lastReplayNotificationError = null; + } + + replay (direction) { + const result = applyPreparedComponentTransaction(this.transaction, { + ...this.adapters, + runtimeCommitMode: ComponentRuntimeCommitMode.APPLY, + direction + }); + if (this.afterReplay) { + try { + this.afterReplay(result); + this.lastReplayNotificationError = null; + } catch (error) { + // Notifications are not authoritative transaction state. Preserve a + // diagnostic without corrupting the undo journal after a valid replay. + this.lastReplayNotificationError = error; + } + } + return this; + } + + apply () { + return this.replay(ComponentTransactionDirection.FORWARD); + } + + reverseApply () { + return this.replay(ComponentTransactionDirection.REVERSE); + } +} diff --git a/lively.ide/components/reconciliation/derived-projector.js b/lively.ide/components/reconciliation/derived-projector.js new file mode 100644 index 0000000000..9bc4ea283b --- /dev/null +++ b/lively.ide/components/reconciliation/derived-projector.js @@ -0,0 +1,470 @@ +import { + ComponentDocument, + ComponentNodeProvenanceKind, + findComponentLayoutModel, + findComponentNode, + findComponentParent +} from './component-document.js'; +import { + layoutPropertyCannotReferenceChildren, + parseComponentSource +} from './source-adapter.js'; + +export const DerivedProjectionDiagnosticKind = Object.freeze({ + INVALID_PARENT_TRANSITION: 'invalid-parent-transition', + DEPENDENCY_GRAPH_INVALID: 'dependency-graph-invalid', + SOURCE_UNSUPPORTED: 'source-unsupported', + PROJECTED_SOURCE_INVALID: 'projected-source-invalid' +}); + +function diagnostic (kind, message, details = {}) { + return Object.freeze({ kind, message, ...details }); +} + +function unsupportedResult (source, diagnostics, beforeDocument = null) { + return Object.freeze({ + supported: false, + sourceBefore: source, + sourceAfter: source, + beforeDocument, + document: null, + changes: Object.freeze([]), + diagnostics: Object.freeze(diagnostics) + }); +} + +function allNodes (document) { + const nodes = []; + const visit = node => { + nodes.push(node); + node.children.forEach(visit); + }; + visit(document.root); + return nodes; +} + +function applyChanges (source, changes) { + return changes + .slice() + .sort((left, right) => right.start - left.start) + .reduce((updated, change) => + `${updated.slice(0, change.start)}${change.text}${updated.slice(change.end)}`, source); +} + +function removeLayoutReferenceChange (document, model, referenceIndex) { + const locations = model.references.map(reference => document.sourceMetadata + .layoutReferenceLocations?.[model.ownerId]?.[reference.targetId]?.entry); + if (locations.some(location => !location)) return null; + const location = locations[referenceIndex]; + const previous = locations[referenceIndex - 1]; + const next = locations[referenceIndex + 1]; + return Object.freeze({ + action: 'remove', + start: next ? location.start : previous ? previous.end : location.start, + end: next ? next.start : location.end, + text: '' + }); +} + +export function projectDerivedComponentRename ({ + source, + moduleId, + exportName, + componentId = `${moduleId}#${exportName}`, + beforeParentDocument, + afterParentDocument, + nodeId, + resolveComponentDocument = null +}) { + if (typeof source !== 'string') throw new Error('Derived rename projection requires source'); + if (!(beforeParentDocument instanceof ComponentDocument) || + !(afterParentDocument instanceof ComponentDocument)) { + throw new Error('Derived rename projection requires parent documents before and after'); + } + const beforeParentNode = findComponentNode(beforeParentDocument, nodeId); + const afterParentNode = findComponentNode(afterParentDocument, nodeId); + if (!beforeParentNode || !afterParentNode || beforeParentNode.name === afterParentNode.name) { + return unsupportedResult(source, [diagnostic( + DerivedProjectionDiagnosticKind.INVALID_PARENT_TRANSITION, + `Parent rename transition is unavailable for ${nodeId}`, + { nodeId } + )]); + } + + const parsedBefore = parseComponentSource({ + source, + moduleId, + exportName, + componentId, + parentDocument: beforeParentDocument, + resolveComponentDocument + }); + if (!parsedBefore.supported || !parsedBefore.document.parentComponent) { + return unsupportedResult(source, [diagnostic( + DerivedProjectionDiagnosticKind.SOURCE_UNSUPPORTED, + 'Derived component source could not be modeled before propagation', + { sourceDiagnostics: parsedBefore.diagnostics } + )]); + } + const parentNode = findComponentParent(parsedBefore.document, nodeId); + const localParentLayout = parentNode && + parsedBefore.document.sourceMetadata.propertyLocations?.[parentNode.id]?.layout; + const parentLayoutModel = parentNode && + findComponentLayoutModel(parsedBefore.document, parentNode.id); + if (localParentLayout && !parentLayoutModel && + !layoutPropertyCannotReferenceChildren(parentNode.properties.layout)) { + return unsupportedResult(source, [diagnostic( + DerivedProjectionDiagnosticKind.SOURCE_UNSUPPORTED, + 'Derived rename propagation cannot safely model owner layout references', + { nodeId } + )], parsedBefore.document); + } + const layoutReference = parentLayoutModel?.references.find( + reference => reference.targetId === nodeId + ); + const layoutReferenceLocation = layoutReference && parsedBefore.document.sourceMetadata + .layoutReferenceLocations?.[parentNode.id]?.[nodeId]?.target; + if (layoutReference && !layoutReferenceLocation) { + return unsupportedResult(source, [diagnostic( + DerivedProjectionDiagnosticKind.SOURCE_UNSUPPORTED, + 'Derived rename propagation has no source location for an owner layout reference', + { nodeId } + )], parsedBefore.document); + } + + const changes = []; + const nameLocation = parsedBefore.document.sourceMetadata + .propertyLocations?.[nodeId]?.name?.value; + if (nameLocation) { + changes.push(Object.freeze({ + action: 'replace', + start: nameLocation.start, + end: nameLocation.end, + text: JSON.stringify(afterParentNode.name) + })); + } + if (layoutReferenceLocation) { + changes.push(Object.freeze({ + action: 'replace', + start: layoutReferenceLocation.start, + end: layoutReferenceLocation.end, + text: JSON.stringify(afterParentNode.name) + })); + } + const suppressionLocations = parsedBefore.document.sourceMetadata + .suppressionLocationLists?.[nodeId] || + [parsedBefore.document.sourceMetadata.suppressionLocations?.[nodeId]].filter(Boolean); + for (const suppressionLocation of suppressionLocations) { + changes.push(Object.freeze({ + action: 'replace', + start: suppressionLocation.start, + end: suppressionLocation.end, + text: `without(${JSON.stringify(afterParentNode.name)})` + })); + } + for (const node of allNodes(parsedBefore.document)) { + if (node.provenance.kind !== ComponentNodeProvenanceKind.ADDED || + node.provenance.beforeId !== nodeId) continue; + const location = parsedBefore.document.sourceMetadata.orderingLocations?.[node.id]; + if (!location) continue; + changes.push(Object.freeze({ + action: 'replace', + start: location.start, + end: location.end, + text: JSON.stringify(afterParentNode.name) + })); + } + + const sourceAfter = applyChanges(source, changes); + const parsedAfter = parseComponentSource({ + source: sourceAfter, + moduleId, + exportName, + componentId, + parentDocument: afterParentDocument, + resolveComponentDocument + }); + if (!parsedAfter.supported) { + return unsupportedResult(source, [diagnostic( + DerivedProjectionDiagnosticKind.PROJECTED_SOURCE_INVALID, + 'Derived component source stopped modeling after rename propagation', + { sourceDiagnostics: parsedAfter.diagnostics } + )], parsedBefore.document); + } + + return Object.freeze({ + supported: true, + sourceBefore: source, + sourceAfter, + beforeDocument: parsedBefore.document, + document: parsedAfter.document, + changes: Object.freeze(changes), + diagnostics: Object.freeze([]) + }); +} + +export function projectDerivedComponentStructure ({ + source, + moduleId, + exportName, + componentId = `${moduleId}#${exportName}`, + beforeParentDocument, + afterParentDocument, + resolveComponentDocument = null +}) { + if (typeof source !== 'string') throw new Error('Derived structure projection requires source'); + if (!(beforeParentDocument instanceof ComponentDocument) || + !(afterParentDocument instanceof ComponentDocument)) { + throw new Error('Derived structure projection requires parent documents before and after'); + } + if (beforeParentDocument === afterParentDocument) { + return unsupportedResult(source, [diagnostic( + DerivedProjectionDiagnosticKind.INVALID_PARENT_TRANSITION, + 'Parent structural transition is unavailable' + )]); + } + const parsedBefore = parseComponentSource({ + source, + moduleId, + exportName, + componentId, + parentDocument: beforeParentDocument, + resolveComponentDocument + }); + if (!parsedBefore.supported || !parsedBefore.document.parentComponent) { + return unsupportedResult(source, [diagnostic( + DerivedProjectionDiagnosticKind.SOURCE_UNSUPPORTED, + 'Derived component source could not be modeled before structural propagation', + { sourceDiagnostics: parsedBefore.diagnostics } + )]); + } + const localLayoutOwnerIds = Object.entries( + parsedBefore.document.sourceMetadata.propertyLocations || {} + ).filter(([, locations]) => locations.layout).map(([ownerId]) => ownerId); + const unmodeledLayoutOwnerId = localLayoutOwnerIds.find(ownerId => + !findComponentLayoutModel(parsedBefore.document, ownerId)); + if (unmodeledLayoutOwnerId) { + return unsupportedResult(source, [diagnostic( + DerivedProjectionDiagnosticKind.SOURCE_UNSUPPORTED, + 'Derived structural propagation cannot safely model owner layout references', + { ownerId: unmodeledLayoutOwnerId } + )], parsedBefore.document); + } + const afterIds = new Set(allNodes(afterParentDocument).map(({ id }) => id)); + const removedRootIds = new Set(allNodes(beforeParentDocument) + .filter(node => !afterIds.has(node.id)) + .filter(node => { + const parent = findComponentParent(beforeParentDocument, node.id); + return parent && afterIds.has(parent.id); + }) + .map(({ id }) => id)); + const hasRetainedRemovalIntent = [...removedRootIds].some(id => + parsedBefore.document.sourceMetadata.nodeIdToAstLocation?.[id] || + parsedBefore.document.sourceMetadata.suppressionLocations?.[id]); + let changes = []; + for (const model of parsedBefore.document.layoutModels) { + if (!afterIds.has(model.ownerId)) continue; + const afterOwner = findComponentNode(afterParentDocument, model.ownerId); + for (let index = 0; index < model.references.length; index++) { + if (afterOwner?.children.some(child => + child.id === model.references[index].targetId)) continue; + const change = removeLayoutReferenceChange(parsedBefore.document, model, index); + if (!change) { + return unsupportedResult(source, [diagnostic( + DerivedProjectionDiagnosticKind.SOURCE_UNSUPPORTED, + 'Derived structural propagation has no source location for a layout reference', + { ownerId: model.ownerId, nodeId: model.references[index].targetId } + )], parsedBefore.document); + } + changes.push(change); + } + } + if (!hasRetainedRemovalIntent && removedRootIds.size) { + changes = [...changes, ...allNodes(parsedBefore.document) + .filter(node => removedRootIds.has(node.provenance.beforeId)) + .map(node => parsedBefore.document.sourceMetadata.orderingLocations?.[node.id]) + .filter(Boolean) + .map(location => { + let start = location.start; + while (start > 0 && /\s/.test(source[start - 1])) start--; + if (source[start - 1] === ',') start--; + return Object.freeze({ + action: 'remove', + start, + end: location.end, + text: '' + }); + })]; + } + let sourceAfter = applyChanges(source, changes); + let parsedAfter = parseComponentSource({ + source: sourceAfter, + moduleId, + exportName, + componentId, + parentDocument: afterParentDocument, + resolveComponentDocument + }); + if (!parsedAfter.supported) { + return unsupportedResult(source, [diagnostic( + DerivedProjectionDiagnosticKind.PROJECTED_SOURCE_INVALID, + 'Derived source is incompatible with the parent structural transition', + { sourceDiagnostics: parsedAfter.diagnostics } + )], parsedBefore.document); + } + return Object.freeze({ + supported: true, + sourceBefore: source, + sourceAfter, + beforeDocument: parsedBefore.document, + document: parsedAfter.document, + changes: Object.freeze(changes), + diagnostics: Object.freeze([]) + }); +} + +function graphFailure (diagnostics, components = []) { + return Object.freeze({ + supported: false, + components: Object.freeze(components.slice()), + modules: Object.freeze([]), + diagnostics: Object.freeze(diagnostics) + }); +} + +/** + * Plans rename propagation through a component-derivation graph without + * mutating descriptors, modules, documents, or runtime instances. + * + * `describeComponent` supplies source and identity for a dependant. Sources + * are threaded per module so multiple component definitions in one module do + * not overwrite one another's projected changes. + */ +function planDerivedComponentPropagation ({ + root, + beforeParentDocument, + afterParentDocument, + getDependants, + describeComponent, + projectComponent +}) { + if (typeof getDependants !== 'function' || typeof describeComponent !== 'function' || + typeof projectComponent !== 'function') { + throw new Error('Derived propagation planning requires graph accessors'); + } + const components = []; + const moduleSources = new Map(); + const visited = new Set(); + const visiting = new Set(); + + const visit = (parent, beforeDocument, afterDocument) => { + const dependants = getDependants(parent); + if (!Array.isArray(dependants)) { + return diagnostic( + DerivedProjectionDiagnosticKind.DEPENDENCY_GRAPH_INVALID, + 'Derived component dependants must be returned as an array' + ); + } + for (const dependant of dependants) { + let description; + try { + description = describeComponent(dependant); + } catch (error) { + return diagnostic( + DerivedProjectionDiagnosticKind.DEPENDENCY_GRAPH_INVALID, + error.message, + { error } + ); + } + const { moduleId, exportName } = description || {}; + if (typeof moduleId !== 'string' || !moduleId || + typeof exportName !== 'string' || !exportName || + typeof description.source !== 'string') { + return diagnostic( + DerivedProjectionDiagnosticKind.DEPENDENCY_GRAPH_INVALID, + 'A derived component is missing source identity' + ); + } + const key = `${moduleId}#${exportName}`; + if (visiting.has(key)) { + return diagnostic( + DerivedProjectionDiagnosticKind.DEPENDENCY_GRAPH_INVALID, + `Component derivation cycle detected at ${key}`, + { moduleId, exportName } + ); + } + if (visited.has(key)) continue; + visiting.add(key); + + const moduleSource = moduleSources.get(moduleId); + const source = moduleSource?.sourceAfter ?? description.source; + if (moduleSource && description.source !== moduleSource.sourceBefore) { + return diagnostic( + DerivedProjectionDiagnosticKind.DEPENDENCY_GRAPH_INVALID, + `Derived components disagree about the source snapshot for ${moduleId}`, + { moduleId, exportName } + ); + } + const projection = projectComponent({ + ...description, + source, + beforeParentDocument: beforeDocument, + afterParentDocument: afterDocument + }); + if (!projection.supported) { + return diagnostic( + DerivedProjectionDiagnosticKind.SOURCE_UNSUPPORTED, + `Could not propagate a rename into ${key}`, + { moduleId, exportName, sourceDiagnostics: projection.diagnostics } + ); + } + + moduleSources.set(moduleId, { + moduleId, + sourceBefore: moduleSource?.sourceBefore ?? description.source, + sourceAfter: projection.sourceAfter + }); + components.push(Object.freeze({ + dependant, + moduleId, + exportName, + projection + })); + const nestedDiagnostic = visit( + dependant, + projection.beforeDocument, + projection.document + ); + if (nestedDiagnostic) return nestedDiagnostic; + visiting.delete(key); + visited.add(key); + } + return null; + }; + + const graphDiagnostic = visit(root, beforeParentDocument, afterParentDocument); + if (graphDiagnostic) return graphFailure([graphDiagnostic], components); + return Object.freeze({ + supported: true, + components: Object.freeze(components), + modules: Object.freeze([...moduleSources.values()].map(plan => Object.freeze(plan))), + diagnostics: Object.freeze([]) + }); +} + +export function planDerivedComponentRenamePropagation (options) { + return planDerivedComponentPropagation({ + ...options, + projectComponent: projectionOptions => projectDerivedComponentRename({ + ...projectionOptions, + nodeId: options.nodeId + }) + }); +} + +export function planDerivedComponentStructurePropagation (options) { + return planDerivedComponentPropagation({ + ...options, + projectComponent: projectDerivedComponentStructure + }); +} diff --git a/lively.ide/components/reconciliation/derived-runtime-projector.js b/lively.ide/components/reconciliation/derived-runtime-projector.js new file mode 100644 index 0000000000..9b9343ea63 --- /dev/null +++ b/lively.ide/components/reconciliation/derived-runtime-projector.js @@ -0,0 +1,320 @@ +import { + MoveMorph, + MorphicChangeSet, + attachedMorph, + detachedMorph +} from 'lively.morphic/changes/index.js'; +import { + ComponentNodeProvenanceKind, + findComponentNode, + findComponentParent +} from './component-document.js'; + +export const DerivedRuntimeStructureProjectionKind = Object.freeze({ + INTRODUCE: 'introduce', + REMOVE: 'remove', + MOVE: 'move' +}); + +function childrenOf (morph) { + return morph?.submorphs || morph?.children || []; +} + +function componentNodeNamePath (document, nodeId) { + const visit = (node, path) => { + if (node.id === nodeId) return path; + for (const child of node.children) { + const found = visit(child, path.concat(child.name)); + if (found) return found; + } + return null; + }; + return visit(document.root, []); +} + +function isSuppressed (node) { + return node.provenance.kind === ComponentNodeProvenanceKind.INHERITED && + node.provenance.suppressed; +} + +function isRuntimeVisible (document, nodeId) { + let node = findComponentNode(document, nodeId); + if (!node) return false; + while (node) { + if (isSuppressed(node)) return false; + node = findComponentParent(document, node.id); + } + return true; +} + +function visibleChildren (document, parent) { + return parent.children.filter(child => isRuntimeVisible(document, child.id)); +} + +function resolveRuntimeNode (root, document, nodeId) { + const path = componentNodeNamePath(document, nodeId); + if (!path) return null; + let target = root; + for (const name of path) { + target = childrenOf(target).find(morph => morph.name === name); + if (!target) return null; + } + return target; +} + +function assertRuntimeOwnerMatchesDocument (owner, document, parent) { + const actualNames = childrenOf(owner).map(({ name }) => name); + const expectedNames = visibleChildren(document, parent).map(({ name }) => name); + if (actualNames.length !== expectedNames.length || + actualNames.some((name, index) => name !== expectedNames[index])) { + throw new Error(`Cached derived runtime owner ${parent.name} has stale children`); + } +} + +function assertNamesMatch (actualNames, document, parent) { + const expectedNames = visibleChildren(document, parent).map(({ name }) => name); + if (actualNames.length !== expectedNames.length || + actualNames.some((name, index) => name !== expectedNames[index])) { + throw new Error(`Cached derived runtime transition for ${parent.name} is incomplete`); + } +} + +function rememberRuntimeMorph (runtimeMorphs, morph) { + if (typeof morph?.id !== 'string' || !morph.id) { + throw new Error('Cached derived runtime nodes require stable morph ids'); + } + const existing = runtimeMorphs.get(morph.id); + if (existing && existing !== morph) { + throw new Error(`Cached derived runtime morph id ${morph.id} is ambiguous`); + } + runtimeMorphs.set(morph.id, morph); +} + +function assertCopyableIntroducedSubtree (document, node) { + if (node.provenance.kind !== ComponentNodeProvenanceKind.INHERITED || + node.provenance.hasLocalOverrides || !isRuntimeVisible(document, node.id)) { + throw new Error( + `Cached derived runtime introduction for ${node.name} requires local synthesis` + ); + } + node.children.forEach(child => assertCopyableIntroducedSubtree(document, child)); +} + +function assertRuntimeSubtreeMatchesDocument (runtimeMorph, document, node) { + if (runtimeMorph?.name !== node.name) { + throw new Error(`Copied runtime node does not match introduced node ${node.name}`); + } + const runtimeChildren = childrenOf(runtimeMorph); + const documentChildren = visibleChildren(document, node); + if (runtimeChildren.length !== documentChildren.length) { + throw new Error(`Copied runtime subtree for ${node.name} has stale children`); + } + runtimeChildren.forEach((child, index) => { + assertRuntimeSubtreeMatchesDocument(child, document, documentChildren[index]); + }); +} + +function operationMetadata (component) { + return { + origin: 'runtime-projection', + reconcileChanges: false, + componentId: component.projection.document.componentId + }; +} + +function introductionOperation (component, nodeId, runtimeMorphs, sourceMorph) { + const { beforeDocument, document: afterDocument } = component.projection; + const root = component.dependant._cachedComponent; + const node = findComponentNode(afterDocument, nodeId); + const parent = node && findComponentParent(afterDocument, nodeId); + const parentBefore = parent && findComponentNode(beforeDocument, parent.id); + const owner = parentBefore && resolveRuntimeNode(root, beforeDocument, parentBefore.id); + if (!node || !parent || !parentBefore || !owner) { + throw new Error( + `Cached derived component ${component.exportName} cannot resolve the introduced runtime owner` + ); + } + assertRuntimeOwnerMatchesDocument(owner, beforeDocument, parentBefore); + assertCopyableIntroducedSubtree(afterDocument, node); + if (!sourceMorph || typeof sourceMorph.copy !== 'function') { + throw new Error( + `Cached derived component ${component.exportName} cannot copy the introduced runtime node` + ); + } + const target = sourceMorph.copy(); + if (!target || target === sourceMorph || target.owner) { + throw new Error( + `Cached derived component ${component.exportName} produced an invalid runtime copy` + ); + } + assertRuntimeSubtreeMatchesDocument(target, afterDocument, node); + const index = visibleChildren(afterDocument, parent) + .findIndex(({ id }) => id === nodeId); + if (index < 0) { + throw new Error( + `Cached derived component ${component.exportName} cannot place the introduced runtime node` + ); + } + const projectedNames = childrenOf(owner).map(({ name }) => name); + projectedNames.splice(index, 0, target.name); + assertNamesMatch(projectedNames, afterDocument, parent); + rememberRuntimeMorph(runtimeMorphs, target); + rememberRuntimeMorph(runtimeMorphs, owner); + return new MoveMorph({ + morphId: target.id, + from: detachedMorph(), + to: attachedMorph({ ownerId: owner.id, index }), + metadata: operationMetadata(component) + }); +} + +function removalOperation (component, nodeId, runtimeMorphs) { + const { beforeDocument, document: afterDocument } = component.projection; + const root = component.dependant._cachedComponent; + const target = resolveRuntimeNode(root, beforeDocument, nodeId); + const parent = findComponentParent(beforeDocument, nodeId); + const owner = parent && resolveRuntimeNode(root, beforeDocument, parent.id); + if (!target || !parent || !owner || target.owner !== owner) { + throw new Error( + `Cached derived component ${component.exportName} is missing the removed runtime node` + ); + } + assertRuntimeOwnerMatchesDocument(owner, beforeDocument, parent); + const index = childrenOf(owner).indexOf(target); + if (index < 0) { + throw new Error( + `Cached derived component ${component.exportName} has stale runtime structure` + ); + } + const projectedNames = childrenOf(owner).map(({ name }) => name); + projectedNames.splice(index, 1); + const afterParent = findComponentNode(afterDocument, parent.id); + if (!afterParent) { + throw new Error( + `Cached derived component ${component.exportName} also removes the runtime owner` + ); + } + assertNamesMatch(projectedNames, afterDocument, afterParent); + rememberRuntimeMorph(runtimeMorphs, target); + rememberRuntimeMorph(runtimeMorphs, owner); + return new MoveMorph({ + morphId: target.id, + from: attachedMorph({ ownerId: owner.id, index }), + to: detachedMorph(), + metadata: operationMetadata(component) + }); +} + +function movementOperation (component, nodeId, runtimeMorphs) { + const { beforeDocument, document: afterDocument } = component.projection; + const root = component.dependant._cachedComponent; + const beforeParent = findComponentParent(beforeDocument, nodeId); + const afterParent = findComponentParent(afterDocument, nodeId); + const destinationParentBefore = afterParent && + findComponentNode(beforeDocument, afterParent.id); + const target = resolveRuntimeNode(root, beforeDocument, nodeId); + const sourceOwner = beforeParent && + resolveRuntimeNode(root, beforeDocument, beforeParent.id); + const destinationOwner = afterParent && + resolveRuntimeNode(root, beforeDocument, afterParent.id); + if (!target || !beforeParent || !afterParent || !destinationParentBefore || + !sourceOwner || !destinationOwner || + target.owner !== sourceOwner) { + throw new Error( + `Cached derived component ${component.exportName} cannot resolve the moved runtime node` + ); + } + assertRuntimeOwnerMatchesDocument(sourceOwner, beforeDocument, beforeParent); + if (destinationOwner !== sourceOwner) { + assertRuntimeOwnerMatchesDocument( + destinationOwner, + beforeDocument, + destinationParentBefore + ); + } + const fromIndex = childrenOf(sourceOwner).indexOf(target); + const toIndex = visibleChildren(afterDocument, afterParent) + .findIndex(({ id }) => id === nodeId); + if (fromIndex < 0 || toIndex < 0) { + throw new Error( + `Cached derived component ${component.exportName} has stale runtime ordering` + ); + } + const sourceNames = childrenOf(sourceOwner).map(({ name }) => name); + const [targetName] = sourceNames.splice(fromIndex, 1); + if (sourceOwner === destinationOwner) { + sourceNames.splice(toIndex, 0, targetName); + assertNamesMatch(sourceNames, afterDocument, afterParent); + } else { + const destinationNames = childrenOf(destinationOwner).map(({ name }) => name); + destinationNames.splice(toIndex, 0, targetName); + const sourceParentAfter = findComponentNode(afterDocument, beforeParent.id); + if (!sourceParentAfter) { + throw new Error( + `Cached derived component ${component.exportName} also removes the source owner` + ); + } + assertNamesMatch(sourceNames, afterDocument, sourceParentAfter); + assertNamesMatch(destinationNames, afterDocument, afterParent); + } + if (sourceOwner === destinationOwner && fromIndex === toIndex) return null; + rememberRuntimeMorph(runtimeMorphs, target); + rememberRuntimeMorph(runtimeMorphs, sourceOwner); + rememberRuntimeMorph(runtimeMorphs, destinationOwner); + return new MoveMorph({ + morphId: target.id, + from: attachedMorph({ ownerId: sourceOwner.id, index: fromIndex }), + to: attachedMorph({ ownerId: destinationOwner.id, index: toIndex }), + metadata: operationMetadata(component) + }); +} + +export function projectCachedDerivedRuntimeStructure ({ + components, + nodeId, + commandKind, + changeSetId, + sourceMorph = null +}) { + if (!Array.isArray(components)) { + throw new Error('Cached derived runtime projection requires component plans'); + } + if (![ + DerivedRuntimeStructureProjectionKind.INTRODUCE, + DerivedRuntimeStructureProjectionKind.REMOVE, + DerivedRuntimeStructureProjectionKind.MOVE + ] + .includes(commandKind)) { + throw new Error(`Unsupported cached derived runtime structure command: ${commandKind}`); + } + const runtimeMorphs = new Map(); + const operations = []; + for (const component of components) { + if (!component?.dependant?._cachedComponent) continue; + const beforeVisible = isRuntimeVisible(component.projection.beforeDocument, nodeId); + const afterVisible = isRuntimeVisible(component.projection.document, nodeId); + if (!beforeVisible && !afterVisible) continue; + const operation = !beforeVisible && afterVisible + ? introductionOperation(component, nodeId, runtimeMorphs, sourceMorph) + : afterVisible + ? movementOperation(component, nodeId, runtimeMorphs) + : removalOperation(component, nodeId, runtimeMorphs); + if (operation) operations.push(operation); + } + if (!operations.length) return null; + const changeSet = new MorphicChangeSet({ + id: `${changeSetId}:derived-structure-runtime`, + label: 'project inherited structure into cached derived components', + origin: 'runtime-projection', + undoable: false, + operations + }); + return Object.freeze({ + changeSet, + inverseChangeSet: changeSet.invert({ + id: `${changeSet.id}:inverse`, + origin: 'runtime-projection' + }), + resolveMorph: id => runtimeMorphs.get(id) + }); +} diff --git a/lively.ide/components/reconciliation/derived-transaction.js b/lively.ide/components/reconciliation/derived-transaction.js new file mode 100644 index 0000000000..a37eced588 --- /dev/null +++ b/lively.ide/components/reconciliation/derived-transaction.js @@ -0,0 +1,389 @@ +import { EditTransaction, EditTransactionKind } from 'lively.morphic/undo.js'; +import { MorphicChangeSet } from 'lively.morphic/changes/index.js'; + +export const DerivedTransactionDirection = Object.freeze({ + FORWARD: 'forward', + REVERSE: 'reverse' +}); + +const directions = new Set(Object.values(DerivedTransactionDirection)); + +function isPromise (value) { + return value && typeof value.then === 'function'; +} + +function storeFor (stores, moduleId) { + const store = stores instanceof Map ? stores.get(moduleId) : stores?.[moduleId]; + if (!store || typeof store.read !== 'function' || typeof store.write !== 'function') { + throw new Error(`Derived propagation requires a source store for ${moduleId}`); + } + return store; +} + +function writeStore (store, source, transaction, moduleId) { + const result = store.write(source, transaction); + if (isPromise(result)) { + throw new Error(`Derived propagation source store for ${moduleId} must be synchronous`); + } + if (store.read() !== source) { + throw new Error(`Derived propagation source store diverged for ${moduleId}`); + } +} + +export class PreparedDerivedPropagationTransaction { + constructor ({ id, modules }) { + if (typeof id !== 'string' || !id) { + throw new Error('Prepared derived propagation transactions require an id'); + } + if (!Array.isArray(modules)) { + throw new Error('Prepared derived propagation transactions require module plans'); + } + const moduleIds = new Set(); + this.modules = Object.freeze(modules + .filter(({ sourceBefore, sourceAfter }) => sourceBefore !== sourceAfter) + .map(plan => { + if (typeof plan?.moduleId !== 'string' || !plan.moduleId || + typeof plan.sourceBefore !== 'string' || + typeof plan.sourceAfter !== 'string') { + throw new Error('Derived propagation module plans require source snapshots'); + } + if (moduleIds.has(plan.moduleId)) { + throw new Error(`Duplicate derived propagation module plan for ${plan.moduleId}`); + } + moduleIds.add(plan.moduleId); + return Object.freeze({ + moduleId: plan.moduleId, + sourceBefore: plan.sourceBefore, + sourceAfter: plan.sourceAfter + }); + })); + this.id = id; + Object.freeze(this); + } +} + +export class DerivedPropagationConflictError extends Error { + constructor (message, transaction) { + super(message); + this.name = 'DerivedPropagationConflictError'; + this.transaction = transaction; + } +} + +export class DerivedPropagationRollbackError extends Error { + constructor (message, cause, rollbackErrors, transaction) { + super(message); + this.name = 'DerivedPropagationRollbackError'; + this.cause = cause; + this.rollbackErrors = Object.freeze(rollbackErrors.slice()); + this.transaction = transaction; + } +} + +function replayFor (plan, direction) { + return direction === DerivedTransactionDirection.FORWARD + ? { sourceBefore: plan.sourceBefore, sourceAfter: plan.sourceAfter } + : { sourceBefore: plan.sourceAfter, sourceAfter: plan.sourceBefore }; +} + +export function validatePreparedDerivedPropagation (transaction, stores, direction) { + if (!(transaction instanceof PreparedDerivedPropagationTransaction)) { + throw new Error('Can only validate a PreparedDerivedPropagationTransaction'); + } + if (!directions.has(direction)) { + throw new Error(`Unknown derived propagation direction: ${direction}`); + } + for (const plan of transaction.modules) { + const store = storeFor(stores, plan.moduleId); + const replay = replayFor(plan, direction); + if (store.read() !== replay.sourceBefore) { + throw new DerivedPropagationConflictError( + `Source changed while derived propagation ${transaction.id} was being planned for ${plan.moduleId}`, + transaction + ); + } + } + return transaction; +} + +export function applyPreparedDerivedPropagation (transaction, { + stores, + direction = DerivedTransactionDirection.FORWARD +}) { + validatePreparedDerivedPropagation(transaction, stores, direction); + const attempted = []; + try { + for (const plan of transaction.modules) { + const store = storeFor(stores, plan.moduleId); + const replay = replayFor(plan, direction); + attempted.push({ plan, store, replay }); + writeStore(store, replay.sourceAfter, transaction, plan.moduleId); + } + } catch (error) { + const rollbackErrors = []; + for (const { plan, store, replay } of attempted.reverse()) { + try { + writeStore(store, replay.sourceBefore, transaction, plan.moduleId); + } catch (rollbackError) { + rollbackErrors.push(rollbackError); + } + } + if (rollbackErrors.length) { + throw new DerivedPropagationRollbackError( + `Failed to apply derived propagation ${transaction.id} and roll it back completely`, + error, + rollbackErrors, + transaction + ); + } + throw error; + } + return Object.freeze({ transaction, direction }); +} + +export class ProjectionalDerivedEditTransaction extends EditTransaction { + constructor (transaction, stores, { label = 'derived component propagation' } = {}) { + if (!(transaction instanceof PreparedDerivedPropagationTransaction)) { + throw new Error('ProjectionalDerivedEditTransaction requires a prepared transaction'); + } + super({ + kind: EditTransactionKind.COMPONENT_COMMAND, + label, + metadata: { derivedPropagationTransactionId: transaction.id } + }); + this.transaction = transaction; + this.stores = stores; + } + + apply () { + applyPreparedDerivedPropagation(this.transaction, { + stores: this.stores, + direction: DerivedTransactionDirection.FORWARD + }); + return this; + } + + reverseApply () { + applyPreparedDerivedPropagation(this.transaction, { + stores: this.stores, + direction: DerivedTransactionDirection.REVERSE + }); + return this; + } +} + +export class PreparedDerivedRuntimeRenameTransaction { + constructor ({ id, renames }) { + if (typeof id !== 'string' || !id) { + throw new Error('Prepared derived runtime transactions require an id'); + } + if (!Array.isArray(renames)) { + throw new Error('Prepared derived runtime transactions require rename plans'); + } + const ids = new Set(); + this.renames = Object.freeze(renames.map(rename => { + if (typeof rename?.id !== 'string' || !rename.id || + typeof rename.beforeName !== 'string' || !rename.beforeName || + typeof rename.afterName !== 'string' || !rename.afterName) { + throw new Error('Derived runtime rename plans require identity and name snapshots'); + } + if (ids.has(rename.id)) { + throw new Error(`Duplicate derived runtime rename plan for ${rename.id}`); + } + ids.add(rename.id); + return Object.freeze({ + id: rename.id, + beforeName: rename.beforeName, + afterName: rename.afterName + }); + })); + this.id = id; + Object.freeze(this); + } +} + +function runtimeStoreFor (stores, id) { + const store = stores instanceof Map ? stores.get(id) : stores?.[id]; + if (!store || typeof store.read !== 'function' || typeof store.write !== 'function') { + throw new Error(`Derived propagation requires a runtime store for ${id}`); + } + return store; +} + +function runtimeReplayFor (rename, direction) { + return direction === DerivedTransactionDirection.FORWARD + ? { nameBefore: rename.beforeName, nameAfter: rename.afterName } + : { nameBefore: rename.afterName, nameAfter: rename.beforeName }; +} + +export function validatePreparedDerivedRuntimeRenames (transaction, stores, direction) { + if (!(transaction instanceof PreparedDerivedRuntimeRenameTransaction)) { + throw new Error('Can only validate a PreparedDerivedRuntimeRenameTransaction'); + } + if (!directions.has(direction)) { + throw new Error(`Unknown derived runtime direction: ${direction}`); + } + for (const rename of transaction.renames) { + const store = runtimeStoreFor(stores, rename.id); + const replay = runtimeReplayFor(rename, direction); + if (store.read() !== replay.nameBefore) { + throw new DerivedPropagationConflictError( + `Runtime changed while derived propagation ${transaction.id} was being planned for ${rename.id}`, + transaction + ); + } + } + return transaction; +} + +export function applyPreparedDerivedRuntimeRenames (transaction, { + stores, + direction = DerivedTransactionDirection.FORWARD +}) { + validatePreparedDerivedRuntimeRenames(transaction, stores, direction); + const attempted = []; + try { + for (const rename of transaction.renames) { + const store = runtimeStoreFor(stores, rename.id); + const replay = runtimeReplayFor(rename, direction); + attempted.push({ rename, store, replay }); + const result = store.write(replay.nameAfter, transaction); + if (isPromise(result)) { + throw new Error(`Derived runtime store for ${rename.id} must be synchronous`); + } + if (store.read() !== replay.nameAfter) { + throw new Error(`Derived runtime store diverged for ${rename.id}`); + } + } + } catch (error) { + const rollbackErrors = []; + for (const { rename, store, replay } of attempted.reverse()) { + try { + store.write(replay.nameBefore, transaction); + if (store.read() !== replay.nameBefore) { + throw new Error(`Derived runtime rollback diverged for ${rename.id}`); + } + } catch (rollbackError) { + rollbackErrors.push(rollbackError); + } + } + if (rollbackErrors.length) { + throw new DerivedPropagationRollbackError( + `Failed to apply derived runtime propagation ${transaction.id} and roll it back completely`, + error, + rollbackErrors, + transaction + ); + } + throw error; + } + return Object.freeze({ transaction, direction }); +} + +export class ProjectionalDerivedRuntimeEditTransaction extends EditTransaction { + constructor (transaction, stores, { label = 'derived runtime propagation' } = {}) { + if (!(transaction instanceof PreparedDerivedRuntimeRenameTransaction)) { + throw new Error('ProjectionalDerivedRuntimeEditTransaction requires a prepared transaction'); + } + super({ + kind: EditTransactionKind.COMPONENT_COMMAND, + label, + metadata: { derivedRuntimeTransactionId: transaction.id } + }); + this.transaction = transaction; + this.stores = stores; + } + + apply () { + applyPreparedDerivedRuntimeRenames(this.transaction, { + stores: this.stores, + direction: DerivedTransactionDirection.FORWARD + }); + return this; + } + + reverseApply () { + applyPreparedDerivedRuntimeRenames(this.transaction, { + stores: this.stores, + direction: DerivedTransactionDirection.REVERSE + }); + return this; + } +} + +export class PreparedDerivedRuntimeChangeTransaction { + constructor ({ id, changeSet, inverseChangeSet }) { + if (typeof id !== 'string' || !id) { + throw new Error('Prepared derived runtime change transactions require an id'); + } + if (!(changeSet instanceof MorphicChangeSet) || + !(inverseChangeSet instanceof MorphicChangeSet)) { + throw new Error('Prepared derived runtime changes require exact morphic change sets'); + } + this.id = id; + this.changeSet = changeSet; + this.inverseChangeSet = inverseChangeSet; + Object.freeze(this); + } +} + +function runtimeChangeSetFor (transaction, direction) { + return direction === DerivedTransactionDirection.FORWARD + ? transaction.changeSet + : transaction.inverseChangeSet; +} + +export function validatePreparedDerivedRuntimeChanges (transaction, runtimeContext, direction) { + if (!(transaction instanceof PreparedDerivedRuntimeChangeTransaction)) { + throw new Error('Can only validate a PreparedDerivedRuntimeChangeTransaction'); + } + if (!directions.has(direction)) { + throw new Error(`Unknown derived runtime change direction: ${direction}`); + } + runtimeChangeSetFor(transaction, direction).validate(runtimeContext); + return transaction; +} + +export function applyPreparedDerivedRuntimeChanges (transaction, { + runtimeContext, + direction = DerivedTransactionDirection.FORWARD +}) { + validatePreparedDerivedRuntimeChanges(transaction, runtimeContext, direction); + runtimeChangeSetFor(transaction, direction).apply(runtimeContext); + return Object.freeze({ transaction, direction }); +} + +export class ProjectionalDerivedRuntimeChangeEditTransaction extends EditTransaction { + constructor (transaction, runtimeContext, { + label = 'derived structural runtime propagation' + } = {}) { + if (!(transaction instanceof PreparedDerivedRuntimeChangeTransaction)) { + throw new Error( + 'ProjectionalDerivedRuntimeChangeEditTransaction requires a prepared transaction' + ); + } + super({ + kind: EditTransactionKind.COMPONENT_COMMAND, + label, + metadata: { derivedRuntimeChangeTransactionId: transaction.id } + }); + this.transaction = transaction; + this.runtimeContext = runtimeContext; + } + + apply () { + applyPreparedDerivedRuntimeChanges(this.transaction, { + runtimeContext: this.runtimeContext, + direction: DerivedTransactionDirection.FORWARD + }); + return this; + } + + reverseApply () { + applyPreparedDerivedRuntimeChanges(this.transaction, { + runtimeContext: this.runtimeContext, + direction: DerivedTransactionDirection.REVERSE + }); + return this; + } +} diff --git a/lively.ide/components/reconciliation/fuzz-random.js b/lively.ide/components/reconciliation/fuzz-random.js new file mode 100644 index 0000000000..fd048f9f5e --- /dev/null +++ b/lively.ide/components/reconciliation/fuzz-random.js @@ -0,0 +1,46 @@ +export function numericFuzzSeed (seed) { + if (Number.isInteger(seed)) return seed >>> 0; + const stringSeed = String(seed); + let hash = 2166136261; + for (let i = 0; i < stringSeed.length; i++) { + hash ^= stringSeed.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} + +export class SeededRandom { + constructor (seed = 0xC0FFEE) { + this.seed = seed; + this.state = numericFuzzSeed(seed); + } + + next () { + this.state = (this.state + 0x6D2B79F5) >>> 0; + let value = this.state; + value = Math.imul(value ^ value >>> 15, value | 1); + value ^= value + Math.imul(value ^ value >>> 7, value | 61); + return ((value ^ value >>> 14) >>> 0) / 4294967296; + } + + integer (min, max) { + return min + Math.floor(this.next() * (max - min)); + } + + boolean (probability = 0.5) { + return this.next() < probability; + } + + pick (items) { + return items.length ? items[this.integer(0, items.length)] : undefined; + } + + shuffle (items) { + const shuffled = items.slice(); + for (let i = shuffled.length - 1; i > 0; i--) { + const j = this.integer(0, i + 1); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + return shuffled; + } +} diff --git a/lively.ide/components/reconciliation/import-bindings.js b/lively.ide/components/reconciliation/import-bindings.js new file mode 100644 index 0000000000..40f5420ec7 --- /dev/null +++ b/lively.ide/components/reconciliation/import-bindings.js @@ -0,0 +1,93 @@ +export const ComponentImportKind = Object.freeze({ + NAMED: 'named', + DEFAULT: 'default', + NAMESPACE: 'namespace' +}); + +const importKinds = new Set(Object.values(ComponentImportKind)); +const identifierPattern = /^[A-Za-z_$][\w$]*$/; + +function requireIdentifier (value, description) { + if (typeof value !== 'string' || !identifierPattern.test(value)) { + throw new Error(`${description} requires a JavaScript identifier`); + } +} + +export function componentImportBinding ({ kind, moduleId, imported, local }) { + if (!importKinds.has(kind)) throw new Error(`Unknown component import kind: ${kind}`); + if (typeof moduleId !== 'string' || !moduleId) { + throw new Error('Component imports require a module ID'); + } + requireIdentifier(local, 'Component imports'); + if (kind === ComponentImportKind.NAMED) { + requireIdentifier(imported, 'Named component imports'); + } else if (imported !== undefined) { + throw new Error(`${kind} component imports do not accept an imported name`); + } + return Object.freeze({ + kind, + moduleId, + ...(kind === ComponentImportKind.NAMED ? { imported } : {}), + local + }); +} + +export function normalizeComponentImportBindings (bindings = []) { + if (!Array.isArray(bindings)) { + throw new Error('Component import bindings must be an array'); + } + const normalized = []; + const byLocal = new Map(); + for (const spec of bindings) { + const binding = componentImportBinding(spec); + const previous = byLocal.get(binding.local); + if (previous) { + if (previous.kind !== binding.kind || previous.moduleId !== binding.moduleId || + previous.imported !== binding.imported) { + throw new Error(`Conflicting component imports for local binding ${binding.local}`); + } + continue; + } + byLocal.set(binding.local, binding); + normalized.push(binding); + } + return Object.freeze(normalized); +} + +// Adapts the expression serializer's legacy module -> exported-name map at +// the boundary. Projection commands only carry validated domain bindings. +export function componentImportBindingsFromExpression (bindings = {}) { + if (!bindings || typeof bindings !== 'object' || Array.isArray(bindings)) { + throw new Error('Serialized expression bindings must be an object'); + } + const specs = []; + for (const [moduleId, rawReferences] of Object.entries(bindings)) { + const references = Array.isArray(rawReferences) ? rawReferences : [rawReferences]; + for (const reference of references) { + if (typeof reference === 'string') { + specs.push({ + kind: ComponentImportKind.NAMED, + moduleId, + imported: reference, + local: reference + }); + continue; + } + const exported = reference?.exported; + const local = reference?.local || exported; + if (exported === 'default') { + specs.push({ kind: ComponentImportKind.DEFAULT, moduleId, local }); + } else if (exported === '*') { + specs.push({ kind: ComponentImportKind.NAMESPACE, moduleId, local }); + } else { + specs.push({ + kind: ComponentImportKind.NAMED, + moduleId, + imported: exported, + local + }); + } + } + } + return normalizeComponentImportBindings(specs); +} diff --git a/lively.ide/components/reconciliation/invariants.js b/lively.ide/components/reconciliation/invariants.js new file mode 100644 index 0000000000..2f99f3d3ef --- /dev/null +++ b/lively.ide/components/reconciliation/invariants.js @@ -0,0 +1,79 @@ +import { + ComponentDocument, + ComponentNodeProvenanceKind +} from './component-document.js'; + +export const ComponentInvariantKind = Object.freeze({ + DUPLICATE_NODE_ID: 'duplicate-node-id', + DUPLICATE_SIBLING_NAME: 'duplicate-sibling-name', + INVALID_ORDERING_REFERENCE: 'invalid-ordering-reference', + INVALID_ROOT_PROVENANCE: 'invalid-root-provenance' +}); + +export class ComponentDocumentInvariantError extends Error { + constructor (diagnostics) { + const message = diagnostics.map(diagnostic => diagnostic.message).join('; '); + super(message); + this.name = 'ComponentDocumentInvariantError'; + this.message = message; + this.diagnostics = diagnostics; + } +} + +export function validateComponentDocument (document) { + if (!(document instanceof ComponentDocument)) { + throw new Error('Can only validate a ComponentDocument'); + } + const diagnostics = []; + const ids = new Set(); + const visit = (node, isRoot = false) => { + if (ids.has(node.id)) { + diagnostics.push(Object.freeze({ + kind: ComponentInvariantKind.DUPLICATE_NODE_ID, + nodeId: node.id, + message: `Duplicate component node ID: ${node.id}` + })); + } + ids.add(node.id); + if (isRoot && node.provenance.kind === ComponentNodeProvenanceKind.INHERITED) { + diagnostics.push(Object.freeze({ + kind: ComponentInvariantKind.INVALID_ROOT_PROVENANCE, + nodeId: node.id, + message: 'A component root cannot be inherited' + })); + } + + const siblingNames = new Set(); + const siblingIds = new Set(node.children.map(child => child.id)); + node.children.forEach(child => { + if (siblingNames.has(child.name)) { + diagnostics.push(Object.freeze({ + kind: ComponentInvariantKind.DUPLICATE_SIBLING_NAME, + nodeId: child.id, + parentId: node.id, + message: `Duplicate sibling name ${child.name} below ${node.id}` + })); + } + siblingNames.add(child.name); + const beforeId = child.provenance.beforeId; + if (beforeId !== undefined && beforeId !== null && + (beforeId === child.id || !siblingIds.has(beforeId))) { + diagnostics.push(Object.freeze({ + kind: ComponentInvariantKind.INVALID_ORDERING_REFERENCE, + nodeId: child.id, + beforeId, + message: `Invalid ordering reference ${beforeId} on ${child.id}` + })); + } + visit(child); + }); + }; + visit(document.root, true); + return Object.freeze(diagnostics); +} + +export function assertComponentDocument (document) { + const diagnostics = validateComponentDocument(document); + if (diagnostics.length) throw new ComponentDocumentInvariantError(diagnostics); + return document; +} diff --git a/lively.ide/components/reconciliation/morphic-change-set-adapter.js b/lively.ide/components/reconciliation/morphic-change-set-adapter.js new file mode 100644 index 0000000000..457c7cd094 --- /dev/null +++ b/lively.ide/components/reconciliation/morphic-change-set-adapter.js @@ -0,0 +1,176 @@ +import { + MorphicAttachmentKind, + MorphicOperationKind +} from 'lively.morphic/changes/index.js'; +import { MorphicChangeSet } from 'lively.morphic/changes/change-set.js'; + +export const ComponentBridgeCommandKind = Object.freeze({ + SET_PROPERTY: 'set-property', + SET_MASTER: 'set-master', + EDIT_TEXT: 'edit-text', + RENAME_NODE: 'rename-node', + INTRODUCE_NODE: 'introduce-node', + REMOVE_NODE: 'remove-node', + MOVE_NODE: 'move-node' +}); + +export const ComponentBridgeDiagnosticKind = Object.freeze({ + UNSUPPORTED_OPERATION: 'unsupported-operation', + PROVENANCE_REQUIRED: 'provenance-required' +}); + +const projectionOrigins = new Set(['runtime-projection', 'source-projection']); + +function setPropertyCommand (componentId, operation, origin) { + if (operation.property === 'name') { + return Object.freeze({ + kind: ComponentBridgeCommandKind.RENAME_NODE, + componentId, + expectedRevision: null, + nodeId: operation.targetId, + previousName: operation.before, + name: operation.after, + origin, + sourceOperation: operation + }); + } + if (operation.property === 'textAndAttributes') { + return Object.freeze({ + kind: ComponentBridgeCommandKind.EDIT_TEXT, + componentId, + expectedRevision: null, + nodeId: operation.targetId, + previousValue: operation.before, + value: operation.after, + origin, + sourceOperation: operation + }); + } + if (operation.property === 'master') { + return Object.freeze({ + kind: ComponentBridgeCommandKind.SET_MASTER, + componentId, + expectedRevision: null, + nodeId: operation.targetId, + previousValue: operation.before, + value: operation.after, + origin, + sourceOperation: operation + }); + } + return Object.freeze({ + kind: ComponentBridgeCommandKind.SET_PROPERTY, + componentId, + expectedRevision: null, + nodeId: operation.targetId, + property: operation.property, + previousValue: operation.before, + value: operation.after, + origin, + sourceOperation: operation + }); +} + +function moveCommand (componentId, operation, origin) { + const { from, to } = operation; + if (from.kind === MorphicAttachmentKind.DETACHED && + to.kind === MorphicAttachmentKind.ATTACHED) { + return Object.freeze({ + kind: ComponentBridgeCommandKind.INTRODUCE_NODE, + componentId, + expectedRevision: null, + nodeId: operation.morphId, + parentId: to.ownerId, + index: to.index, + origin, + sourceOperation: operation + }); + } + if (from.kind === MorphicAttachmentKind.ATTACHED && + to.kind === MorphicAttachmentKind.DETACHED) { + return Object.freeze({ + kind: ComponentBridgeCommandKind.REMOVE_NODE, + componentId, + expectedRevision: null, + nodeId: operation.morphId, + parentId: from.ownerId, + index: from.index, + origin, + sourceOperation: operation + }); + } + return Object.freeze({ + kind: ComponentBridgeCommandKind.MOVE_NODE, + componentId, + expectedRevision: null, + nodeId: operation.morphId, + previousParentId: from.ownerId, + previousIndex: from.index, + parentId: to.ownerId, + index: to.index, + origin, + sourceOperation: operation + }); +} + +function morphsFor (operation, context) { + const ids = [operation.targetId]; + if (operation.kind === MorphicOperationKind.MOVE_MORPH) { + if (operation.from.ownerId) ids.push(operation.from.ownerId); + if (operation.to.ownerId) ids.push(operation.to.ownerId); + } + return ids.map(id => context.resolveMorph?.(id)).filter(Boolean); +} + +export class MorphicChangeSetAdapter { + constructor ({ + componentId, + containsMorph = () => true, + ignoreOperation = () => false + }) { + if (typeof componentId !== 'string' || !componentId) { + throw new Error('MorphicChangeSetAdapter requires a componentId'); + } + this.componentId = componentId; + this.containsMorph = containsMorph; + this.ignoreOperation = ignoreOperation; + } + + adapt (changeSet, context = {}) { + if (!(changeSet instanceof MorphicChangeSet)) { + throw new Error('MorphicChangeSetAdapter can only adapt MorphicChangeSets'); + } + if (projectionOrigins.has(changeSet.origin)) { + return Object.freeze({ + commands: Object.freeze([]), + diagnostics: Object.freeze([]), + ignoredProjection: true + }); + } + + const commands = []; + const diagnostics = []; + changeSet.operations.forEach(operation => { + if (this.ignoreOperation(operation, context)) return; + if (!morphsFor(operation, context).some(this.containsMorph)) return; + if (operation.kind === MorphicOperationKind.SET_MORPH_PROPERTY) { + commands.push(setPropertyCommand(this.componentId, operation, changeSet.origin)); + return; + } + if (operation.kind === MorphicOperationKind.MOVE_MORPH) { + commands.push(moveCommand(this.componentId, operation, changeSet.origin)); + return; + } + diagnostics.push(Object.freeze({ + kind: ComponentBridgeDiagnosticKind.UNSUPPORTED_OPERATION, + operationKind: operation.kind + })); + }); + + return Object.freeze({ + commands: Object.freeze(commands), + diagnostics: Object.freeze(diagnostics), + ignoredProjection: false + }); + } +} diff --git a/lively.ide/components/reconciliation/policy-cache-transaction.js b/lively.ide/components/reconciliation/policy-cache-transaction.js new file mode 100644 index 0000000000..0bdf1db704 --- /dev/null +++ b/lively.ide/components/reconciliation/policy-cache-transaction.js @@ -0,0 +1,332 @@ +import { EditTransaction, EditTransactionKind } from 'lively.morphic/undo.js'; + +export const PolicyCacheTransactionDirection = Object.freeze({ + FORWARD: 'forward', + REVERSE: 'reverse' +}); + +const directions = new Set(Object.values(PolicyCacheTransactionDirection)); + +function storeFor (stores, id) { + const store = stores instanceof Map ? stores.get(id) : stores?.[id]; + if (!store || typeof store.read !== 'function' || typeof store.write !== 'function') { + throw new Error(`Policy cache synchronization requires a store for ${id}`); + } + return store; +} + +function replayFor (rename, direction) { + return direction === PolicyCacheTransactionDirection.FORWARD + ? { nameBefore: rename.beforeName, nameAfter: rename.afterName } + : { nameBefore: rename.afterName, nameAfter: rename.beforeName }; +} + +function writeStore (store, name, transaction, id) { + const result = store.write(name, transaction); + if (result && typeof result.then === 'function') { + throw new Error(`Policy cache store for ${id} must be synchronous`); + } + if (store.read() !== name) { + throw new Error(`Policy cache store diverged for ${id}`); + } +} + +export class PreparedPolicyCacheRenameTransaction { + constructor ({ id, renames }) { + if (typeof id !== 'string' || !id) { + throw new Error('Prepared policy cache transactions require an id'); + } + if (!Array.isArray(renames)) { + throw new Error('Prepared policy cache transactions require rename plans'); + } + const ids = new Set(); + this.renames = Object.freeze(renames.map(rename => { + if (typeof rename?.id !== 'string' || !rename.id || + typeof rename.beforeName !== 'string' || !rename.beforeName || + typeof rename.afterName !== 'string' || !rename.afterName) { + throw new Error('Policy cache rename plans require identity and name snapshots'); + } + if (ids.has(rename.id)) { + throw new Error(`Duplicate policy cache rename plan for ${rename.id}`); + } + ids.add(rename.id); + return Object.freeze({ + id: rename.id, + beforeName: rename.beforeName, + afterName: rename.afterName + }); + })); + this.id = id; + Object.freeze(this); + } +} + +export class PreparedPolicyCachePropertyTransaction { + constructor ({ id, changes }) { + if (typeof id !== 'string' || !id) { + throw new Error('Prepared policy cache property transactions require an id'); + } + if (!Array.isArray(changes)) { + throw new Error('Prepared policy cache property transactions require change plans'); + } + const ids = new Set(); + this.changes = Object.freeze(changes.map(change => { + if (typeof change?.id !== 'string' || !change.id || + typeof change.property !== 'string' || !change.property || + !Object.prototype.hasOwnProperty.call(change, 'beforeValue') || + !Object.prototype.hasOwnProperty.call(change, 'afterValue')) { + throw new Error('Policy cache property plans require identity, property, and value snapshots'); + } + if (ids.has(change.id)) { + throw new Error(`Duplicate policy cache property plan for ${change.id}`); + } + ids.add(change.id); + return Object.freeze({ + id: change.id, + property: change.property, + beforeValue: change.beforeValue, + afterValue: change.afterValue + }); + })); + this.id = id; + Object.freeze(this); + } +} + +export class PolicyCacheConflictError extends Error { + constructor (message, transaction) { + super(message); + this.name = 'PolicyCacheConflictError'; + this.transaction = transaction; + } +} + +export class PolicyCacheRollbackError extends Error { + constructor (message, cause, rollbackErrors, transaction) { + super(message); + this.name = 'PolicyCacheRollbackError'; + this.cause = cause; + this.rollbackErrors = Object.freeze(rollbackErrors.slice()); + this.transaction = transaction; + } +} + +export function validatePreparedPolicyCacheRenames (transaction, stores, direction) { + if (!(transaction instanceof PreparedPolicyCacheRenameTransaction)) { + throw new Error('Can only validate a PreparedPolicyCacheRenameTransaction'); + } + if (!directions.has(direction)) { + throw new Error(`Unknown policy cache transaction direction: ${direction}`); + } + for (const rename of transaction.renames) { + const replay = replayFor(rename, direction); + if (storeFor(stores, rename.id).read() !== replay.nameBefore) { + throw new PolicyCacheConflictError( + `Policy cache changed while transaction ${transaction.id} was being planned for ${rename.id}`, + transaction + ); + } + } + return transaction; +} + +export function applyPreparedPolicyCacheRenames (transaction, { + stores, + direction = PolicyCacheTransactionDirection.FORWARD +}) { + validatePreparedPolicyCacheRenames(transaction, stores, direction); + const attempted = []; + try { + for (const rename of transaction.renames) { + const store = storeFor(stores, rename.id); + const replay = replayFor(rename, direction); + attempted.push({ rename, store, replay }); + writeStore(store, replay.nameAfter, transaction, rename.id); + } + } catch (error) { + const rollbackErrors = []; + for (const { rename, store, replay } of attempted.reverse()) { + try { + writeStore(store, replay.nameBefore, transaction, rename.id); + } catch (rollbackError) { + rollbackErrors.push(rollbackError); + } + } + if (rollbackErrors.length) { + throw new PolicyCacheRollbackError( + `Failed to synchronize policy cache ${transaction.id} and roll it back completely`, + error, + rollbackErrors, + transaction + ); + } + throw error; + } + return Object.freeze({ transaction, direction }); +} + +function propertyReplayFor (change, direction) { + return direction === PolicyCacheTransactionDirection.FORWARD + ? { valueBefore: change.beforeValue, valueAfter: change.afterValue } + : { valueBefore: change.afterValue, valueAfter: change.beforeValue }; +} + +export function validatePreparedPolicyCacheProperties (transaction, stores, direction) { + if (!(transaction instanceof PreparedPolicyCachePropertyTransaction)) { + throw new Error('Can only validate a PreparedPolicyCachePropertyTransaction'); + } + if (!directions.has(direction)) { + throw new Error(`Unknown policy cache transaction direction: ${direction}`); + } + for (const change of transaction.changes) { + const replay = propertyReplayFor(change, direction); + if (storeFor(stores, change.id).read() !== replay.valueBefore) { + throw new PolicyCacheConflictError( + `Policy cache changed while transaction ${transaction.id} was being planned for ${change.id}`, + transaction + ); + } + } + return transaction; +} + +export function applyPreparedPolicyCacheProperties (transaction, { + stores, + direction = PolicyCacheTransactionDirection.FORWARD +}) { + validatePreparedPolicyCacheProperties(transaction, stores, direction); + const attempted = []; + try { + for (const change of transaction.changes) { + const store = storeFor(stores, change.id); + const replay = propertyReplayFor(change, direction); + attempted.push({ change, store, replay }); + const result = store.write(replay.valueAfter, transaction); + if (result && typeof result.then === 'function') { + throw new Error(`Policy cache store for ${change.id} must be synchronous`); + } + if (store.read() !== replay.valueAfter) { + throw new Error(`Policy cache store diverged for ${change.id}`); + } + } + } catch (error) { + const rollbackErrors = []; + for (const { change, store, replay } of attempted.reverse()) { + try { + const result = store.write(replay.valueBefore, transaction); + if (result && typeof result.then === 'function') { + throw new Error(`Policy cache store for ${change.id} must be synchronous`); + } + if (store.read() !== replay.valueBefore) { + throw new Error(`Policy cache store diverged for ${change.id}`); + } + } catch (rollbackError) { + rollbackErrors.push(rollbackError); + } + } + if (rollbackErrors.length) { + throw new PolicyCacheRollbackError( + `Failed to synchronize policy cache ${transaction.id} and roll it back completely`, + error, + rollbackErrors, + transaction + ); + } + throw error; + } + return Object.freeze({ transaction, direction }); +} + +export class ProjectionalPolicyCacheEditTransaction extends EditTransaction { + constructor (transaction, stores, { + label = 'component policy cache synchronization', + afterReplay = null + } = {}) { + if (!(transaction instanceof PreparedPolicyCacheRenameTransaction)) { + throw new Error('ProjectionalPolicyCacheEditTransaction requires a prepared transaction'); + } + if (afterReplay !== null && typeof afterReplay !== 'function') { + throw new Error('Policy cache replay notifications must be functions'); + } + super({ + kind: EditTransactionKind.COMPONENT_COMMAND, + label, + metadata: { policyCacheTransactionId: transaction.id } + }); + this.transaction = transaction; + this.stores = stores; + this.afterReplay = afterReplay; + this.lastReplayNotificationError = null; + } + + replay (direction) { + applyPreparedPolicyCacheRenames(this.transaction, { + stores: this.stores, + direction + }); + if (this.afterReplay) { + try { + this.afterReplay({ transaction: this.transaction, direction }); + this.lastReplayNotificationError = null; + } catch (error) { + this.lastReplayNotificationError = error; + } + } + return this; + } + + apply () { + return this.replay(PolicyCacheTransactionDirection.FORWARD); + } + + reverseApply () { + return this.replay(PolicyCacheTransactionDirection.REVERSE); + } +} + +export class ProjectionalPolicyCachePropertyEditTransaction extends EditTransaction { + constructor (transaction, stores, { + label = 'component policy cache property synchronization', + afterReplay = null + } = {}) { + if (!(transaction instanceof PreparedPolicyCachePropertyTransaction)) { + throw new Error('ProjectionalPolicyCachePropertyEditTransaction requires a prepared transaction'); + } + if (afterReplay !== null && typeof afterReplay !== 'function') { + throw new Error('Policy cache replay notifications must be functions'); + } + super({ + kind: EditTransactionKind.COMPONENT_COMMAND, + label, + metadata: { policyCacheTransactionId: transaction.id } + }); + this.transaction = transaction; + this.stores = stores; + this.afterReplay = afterReplay; + this.lastReplayNotificationError = null; + } + + replay (direction) { + applyPreparedPolicyCacheProperties(this.transaction, { + stores: this.stores, + direction + }); + if (this.afterReplay) { + try { + this.afterReplay({ transaction: this.transaction, direction }); + this.lastReplayNotificationError = null; + } catch (error) { + this.lastReplayNotificationError = error; + } + } + return this; + } + + apply () { + return this.replay(PolicyCacheTransactionDirection.FORWARD); + } + + reverseApply () { + return this.replay(PolicyCacheTransactionDirection.REVERSE); + } +} diff --git a/lively.ide/components/reconciliation/reducer.js b/lively.ide/components/reconciliation/reducer.js new file mode 100644 index 0000000000..fa8479bc22 --- /dev/null +++ b/lively.ide/components/reconciliation/reducer.js @@ -0,0 +1,828 @@ +import { + addedNodeProvenance, + ComponentDocument, + ComponentNode, + ComponentNodeProvenanceKind, + ComponentPropertyKind, + explicitProperty, + findComponentNode, + findComponentParent, + inheritedNodeProvenance, + localNodeProvenance, + tilingLayoutModel +} from './component-document.js'; +import { + ClearPropertyOverride, + ComponentCommandKind, + ComponentMoveInheritanceTransitionKind, + EditText, + IntroduceNode, + MoveNode, + RemoveNode, + RenameNode, + RestoreInheritedNode, + SetPropertyEntry, + SuppressInheritedNode +} from './commands.js'; +import { assertComponentDocument } from './invariants.js'; + +export const ComponentSemanticDeltaKind = Object.freeze({ + PROPERTY_SET: 'property-set', + PROPERTY_CLEARED: 'property-cleared', + NODE_RENAMED: 'node-renamed', + NODE_INTRODUCED: 'node-introduced', + NODE_MOVED: 'node-moved', + NODE_REMOVED: 'node-removed', + NODE_SUPPRESSED: 'node-suppressed', + NODE_RESTORED: 'node-restored', + TEXT_EDITED: 'text-edited' +}); + +export class ComponentCommandError extends Error { + constructor (message, command) { + super(message); + this.name = 'ComponentCommandError'; + this.message = message; + this.command = command; + } +} + +function replaceNode (node, nodeId, replacement) { + if (node.id === nodeId) return replacement(node); + let changed = false; + const children = node.children.map(child => { + const replaced = replaceNode(child, nodeId, replacement); + if (replaced !== child) changed = true; + return replaced; + }); + return changed ? node.with({ children }) : node; +} + +function insertChild (parent, child, beforeId, command) { + let index = parent.children.length; + if (beforeId !== null && beforeId !== undefined) { + index = parent.children.findIndex(candidate => candidate.id === beforeId); + if (index < 0) throw new ComponentCommandError(`Unknown ordering anchor ${beforeId}`, command); + } + const children = parent.children.slice(); + children.splice(index, 0, child); + return parent.with({ children }); +} + +function runtimeVisible (node) { + return node.provenance.kind !== ComponentNodeProvenanceKind.INHERITED || + !node.provenance.suppressed; +} + +function runtimeIndexAt (parent, semanticIndex) { + return parent.children.slice(0, semanticIndex).filter(runtimeVisible).length; +} + +function removeNodeFromTree (root, nodeId) { + let removed = null; + const visit = node => { + const index = node.children.findIndex(child => child.id === nodeId); + if (index > -1) { + const children = node.children.slice(); + removed = children.splice(index, 1)[0]; + return node.with({ children }); + } + let changed = false; + const children = node.children.map(child => { + const next = visit(child); + if (next !== child) changed = true; + return next; + }); + return changed ? node.with({ children }) : node; + }; + return { root: visit(root), removed }; +} + +function descendantIds (node, ids = new Set()) { + ids.add(node.id); + node.children.forEach(child => descendantIds(child, ids)); + return ids; +} + +function materializedContentEqualsInherited (materializedNode, inheritedNode) { + return materializedNode.name === inheritedNode.name && + materializedNode.typeExpression === inheritedNode.typeExpression && + materializedNode.partComponent?.expression === inheritedNode.partComponent?.expression && + semanticValuesEqual(materializedNode.properties, inheritedNode.properties) && + materializedNode.children.length === inheritedNode.children.length && + materializedNode.children.every(materializedChild => { + const inheritedChild = inheritedNode.children.find( + child => child.name === materializedChild.name + ); + return inheritedChild && + materializedContentEqualsInherited(materializedChild, inheritedChild); + }); +} + +function consolidateMaterializedSubtree (materializedNode, inheritedNode, idMap = new Map()) { + idMap.set(materializedNode.id, inheritedNode.id); + const inheritedChildren = new Map( + inheritedNode.children.map(child => [child.name, child]) + ); + const children = materializedNode.children.map(child => { + const inheritedChild = inheritedChildren.get(child.name); + return inheritedChild + ? consolidateMaterializedSubtree(child, inheritedChild, idMap).node + : child; + }); + const provenance = inheritedNode.provenance.kind === + ComponentNodeProvenanceKind.INHERITED + ? inheritedNodeProvenance({ + ...inheritedNode.provenance, + suppressed: materializedNode.provenance.kind === + ComponentNodeProvenanceKind.INHERITED + ? materializedNode.provenance.suppressed + : false, + hasLocalOverrides: inheritedNode.provenance.hasLocalOverrides || + !materializedContentEqualsInherited(materializedNode, inheritedNode) + }) + : materializedNode.provenance; + return { + idMap, + node: materializedNode.with({ + id: inheritedNode.id, + provenance, + children + }) + }; +} + +function remapSubtreeReferences (node, idMap) { + let provenance = node.provenance; + if (provenance.kind === ComponentNodeProvenanceKind.ADDED) { + provenance = addedNodeProvenance({ + beforeId: idMap.get(provenance.beforeId) || provenance.beforeId, + beforeName: provenance.beforeName + }); + } else if (provenance.kind === ComponentNodeProvenanceKind.INHERITED) { + provenance = inheritedNodeProvenance({ + ...provenance, + beforeId: idMap.get(provenance.beforeId) || provenance.beforeId + }); + } + return node.with({ + provenance, + children: node.children.map(child => remapSubtreeReferences(child, idMap)) + }); +} + +function remapLayoutModels (layoutModels, idMap) { + return layoutModels.map(model => tilingLayoutModel({ + ...model, + ownerId: idMap.get(model.ownerId) || model.ownerId, + references: model.references.map(reference => ({ + ...reference, + targetId: idMap.get(reference.targetId) || reference.targetId + })) + })); +} + +function nextRevision (document) { return document.revision + 1; } + +function semanticValuesEqual (left, right) { + if (Object.is(left, right)) return true; + if (Array.isArray(left) && Array.isArray(right)) { + return left.length === right.length && + left.every((value, index) => semanticValuesEqual(value, right[index])); + } + if (left && right && Object.getPrototypeOf(left) === Object.prototype && + Object.getPrototypeOf(right) === Object.prototype) { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + return leftKeys.length === rightKeys.length && + leftKeys.every(key => Object.prototype.hasOwnProperty.call(right, key) && + semanticValuesEqual(left[key], right[key])); + } + return false; +} + +function result ( + document, + root, + inverseCommand, + semanticDelta, + layoutModels = document.layoutModels +) { + const nextDocument = new ComponentDocument({ + revision: nextRevision(document), + componentId: document.componentId, + moduleId: document.moduleId, + exportName: document.exportName, + parentComponent: document.parentComponent, + root, + layoutModels, + sourceMetadata: document.sourceMetadata + }); + assertComponentDocument(nextDocument); + return Object.freeze({ + document: nextDocument, + inverseCommand, + semanticDelta: Object.freeze(semanticDelta), + diagnostics: Object.freeze([]) + }); +} + +function withoutParentLayoutReference (document, parentId, nodeId) { + const modelIndex = document.layoutModels.findIndex(model => model.ownerId === parentId); + if (modelIndex < 0) { + return Object.freeze({ + layoutModels: document.layoutModels, + parentLayoutReference: null + }); + } + const model = document.layoutModels[modelIndex]; + const referenceIndex = model.references.findIndex(reference => reference.targetId === nodeId); + if (referenceIndex < 0) { + return Object.freeze({ + layoutModels: document.layoutModels, + parentLayoutReference: null + }); + } + const references = model.references.slice(); + const [reference] = references.splice(referenceIndex, 1); + const layoutModels = document.layoutModels.slice(); + layoutModels[modelIndex] = tilingLayoutModel({ ...model, references }); + return Object.freeze({ + layoutModels: Object.freeze(layoutModels), + parentLayoutReference: Object.freeze({ index: referenceIndex, reference }) + }); +} + +function withParentLayoutReference (layoutModels, parentId, nodeId, state, command) { + if (!state) return layoutModels; + if (state.reference.targetId !== nodeId) { + throw new ComponentCommandError( + 'Introduced layout references must target the introduced node', + command + ); + } + const modelIndex = layoutModels.findIndex(model => model.ownerId === parentId); + if (modelIndex < 0) { + throw new ComponentCommandError(`Unknown parent layout model ${parentId}`, command); + } + const model = layoutModels[modelIndex]; + if (state.index > model.references.length || + model.references.some(reference => reference.targetId === nodeId)) { + throw new ComponentCommandError('Invalid introduced layout-reference position', command); + } + const references = model.references.slice(); + references.splice(state.index, 0, state.reference); + const updatedLayoutModels = layoutModels.slice(); + updatedLayoutModels[modelIndex] = tilingLayoutModel({ ...model, references }); + return Object.freeze(updatedLayoutModels); +} + +function withSubtreeLayoutModels (layoutModels, states, command) { + if (!states?.length) return layoutModels; + const updatedLayoutModels = layoutModels.slice(); + const ownerIds = new Set(updatedLayoutModels.map(model => model.ownerId)); + for (const { index, model } of states) { + if (index > updatedLayoutModels.length || ownerIds.has(model.ownerId)) { + throw new ComponentCommandError('Invalid introduced subtree layout-model position', command); + } + updatedLayoutModels.splice(index, 0, model); + ownerIds.add(model.ownerId); + } + return Object.freeze(updatedLayoutModels); +} + +function requireNode (document, nodeId, command) { + const node = findComponentNode(document, nodeId); + if (!node) throw new ComponentCommandError(`Unknown component node ${nodeId}`, command); + return node; +} + +function reduceProperty (document, command, property = command.property, entry = command.entry) { + const node = requireNode(document, command.nodeId, command); + const previousEntry = node.properties[property]; + const properties = { ...node.properties, [property]: entry }; + const root = replaceNode(document.root, node.id, current => current.with({ properties })); + const inverseCommand = previousEntry + ? SetPropertyEntry({ + componentId: document.componentId, + expectedRevision: nextRevision(document), + nodeId: node.id, + property, + entry: previousEntry + }) + : ClearPropertyOverride({ + componentId: document.componentId, + expectedRevision: nextRevision(document), + nodeId: node.id, + property + }); + return result(document, root, inverseCommand, { + kind: ComponentSemanticDeltaKind.PROPERTY_SET, + nodeId: node.id, + property, + before: previousEntry, + after: entry, + requiredBindings: command.requiredBindings || Object.freeze([]) + }); +} + +function reduceClearProperty (document, command) { + const node = requireNode(document, command.nodeId, command); + const previousEntry = node.properties[command.property]; + if (!previousEntry) { + throw new ComponentCommandError(`No local override for ${node.id}.${command.property}`, command); + } + const properties = { ...node.properties }; + delete properties[command.property]; + const root = replaceNode(document.root, node.id, current => current.with({ properties })); + return result(document, root, SetPropertyEntry({ + componentId: document.componentId, + expectedRevision: nextRevision(document), + nodeId: node.id, + property: command.property, + entry: previousEntry + }), { + kind: ComponentSemanticDeltaKind.PROPERTY_CLEARED, + nodeId: node.id, + property: command.property, + before: previousEntry + }); +} + +function reduceRename (document, command) { + const node = requireNode(document, command.nodeId, command); + const root = replaceNode(document.root, node.id, current => current.with({ name: command.name })); + return result(document, root, RenameNode({ + componentId: document.componentId, + expectedRevision: nextRevision(document), + nodeId: node.id, + name: node.name + }), { + kind: ComponentSemanticDeltaKind.NODE_RENAMED, + nodeId: node.id, + before: node.name, + after: command.name + }); +} + +function reduceIntroduce (document, command) { + if (findComponentNode(document, command.node.id)) { + throw new ComponentCommandError(`Duplicate component node ID ${command.node.id}`, command); + } + if (command.node.provenance.kind === ComponentNodeProvenanceKind.INHERITED) { + throw new ComponentCommandError('Inherited nodes must be restored, not introduced', command); + } + const parent = requireNode(document, command.parentId, command); + const index = command.beforeId === null || command.beforeId === undefined + ? parent.children.length + : parent.children.findIndex(child => child.id === command.beforeId); + const runtimeIndex = command.runtimeIndex ?? runtimeIndexAt(parent, index); + const root = replaceNode(document.root, parent.id, current => + insertChild(current, command.node, command.beforeId, command)); + const layoutModels = withParentLayoutReference( + withSubtreeLayoutModels(document.layoutModels, command.subtreeLayoutModels, command), + parent.id, + command.node.id, + command.parentLayoutReference, + command + ); + return result(document, root, RemoveNode({ + componentId: document.componentId, + expectedRevision: nextRevision(document), + nodeId: command.node.id, + runtimeIndex + }), { + kind: ComponentSemanticDeltaKind.NODE_INTRODUCED, + nodeId: command.node.id, + parentId: parent.id, + beforeId: command.beforeId ?? null, + index, + runtimeIndex, + requiredBindings: command.requiredBindings + }, layoutModels); +} + +function reduceRemove (document, command) { + const node = requireNode(document, command.nodeId, command); + if (node === document.root) throw new ComponentCommandError('Cannot remove a component root', command); + if (node.provenance.kind === ComponentNodeProvenanceKind.INHERITED) { + throw new ComponentCommandError('Inherited nodes must be suppressed, not removed', command); + } + const parent = findComponentParent(document, node.id); + const index = parent.children.indexOf(node); + const runtimeIndex = command.runtimeIndex ?? runtimeIndexAt(parent, index); + const beforeId = parent.children[index + 1]?.id ?? null; + const rootWithOrderingAnchors = retargetAddedOrderingAnchors( + document.root, + parent.id, + node.id, + beforeId + ); + const { root } = removeNodeFromTree(rootWithOrderingAnchors, node.id); + const withoutReference = withoutParentLayoutReference( + document, + parent.id, + node.id + ); + const removedIds = descendantIds(node); + const subtreeLayoutModels = withoutReference.layoutModels.flatMap((model, index) => + removedIds.has(model.ownerId) ? [{ index, model }] : []); + const layoutModels = withoutReference.layoutModels + .filter(model => !removedIds.has(model.ownerId)); + return result(document, root, IntroduceNode({ + componentId: document.componentId, + expectedRevision: nextRevision(document), + nodeId: node.id, + parentId: parent.id, + node, + beforeId, + runtimeIndex, + ...(withoutReference.parentLayoutReference + ? { parentLayoutReference: withoutReference.parentLayoutReference } + : {}), + ...(subtreeLayoutModels.length ? { subtreeLayoutModels } : {}) + }), { + kind: ComponentSemanticDeltaKind.NODE_REMOVED, + nodeId: node.id, + parentId: parent.id, + index, + runtimeIndex + }, layoutModels); +} + +function retargetAddedOrderingAnchors (root, parentId, removedNodeId, beforeId) { + return replaceNode(root, parentId, current => + current.with({ + children: current.children.map(child => + child.provenance.kind === ComponentNodeProvenanceKind.ADDED && + child.provenance.beforeId === removedNodeId + ? child.with({ provenance: addedNodeProvenance({ beforeId }) }) + : child) + })); +} + +function reduceMove (document, command) { + const node = requireNode(document, command.nodeId, command); + if (node === document.root) throw new ComponentCommandError('Cannot move a component root', command); + const oldParent = findComponentParent(document, node.id); + const oldIndex = oldParent.children.indexOf(node); + const runtimeFromIndex = command.runtimeFromIndex ?? runtimeIndexAt(oldParent, oldIndex); + const oldBeforeId = oldParent.children[oldIndex + 1]?.id ?? null; + const destination = requireNode(document, command.parentId, command); + if (descendantIds(node).has(destination.id)) { + throw new ComponentCommandError('Cannot move a node into its own subtree', command); + } + if (command.beforeId === node.id) { + throw new ComponentCommandError('A node cannot be ordered before itself', command); + } + const crossesSourceOwnershipBoundary = oldParent.id !== destination.id; + const orderingDependants = crossesSourceOwnershipBoundary + ? oldParent.children.filter(child => + child.provenance.kind === ComponentNodeProvenanceKind.ADDED && + child.provenance.beforeId === node.id) + : []; + const inverseOrderingRestorations = new Map(orderingDependants.map(child => [ + child.id, + { + nodeId: child.id, + beforeId: child.provenance.beforeId, + beforeName: child.provenance.beforeName + } + ])); + for (const restoration of command.orderingRestorations || []) { + const restoredNode = requireNode(document, restoration.nodeId, command); + if (restoredNode.provenance.kind !== ComponentNodeProvenanceKind.ADDED) { + throw new ComponentCommandError('Only added-node ordering can be restored', command); + } + inverseOrderingRestorations.set(restoredNode.id, { + nodeId: restoredNode.id, + beforeId: restoredNode.provenance.beforeId, + beforeName: restoredNode.provenance.beforeName + }); + } + if (command.inheritanceTransition?.kind === + ComponentMoveInheritanceTransitionKind.MATERIALIZE) { + if (node.provenance.kind !== ComponentNodeProvenanceKind.INHERITED || + node.provenance.suppressed) { + throw new ComponentCommandError('Only visible inherited nodes can be materialized', command); + } + const materializedNode = command.inheritanceTransition.node; + if (materializedNode.provenance.kind === ComponentNodeProvenanceKind.INHERITED || + findComponentNode(document, materializedNode.id)) { + throw new ComponentCommandError('Materialized inherited nodes require a new local identity', command); + } + const suppressed = node.with({ + provenance: inheritedNodeProvenance({ ...node.provenance, suppressed: true }) + }); + let root = replaceNode(document.root, node.id, () => suppressed); + const destinationAfterSuppression = findComponentNode( + new ComponentDocument({ ...document, root }), + destination.id + ); + root = replaceNode(root, destinationAfterSuppression.id, current => + insertChild(current, materializedNode, command.beforeId, command)); + const movedDocument = new ComponentDocument({ ...document, root }); + const destinationAfterMove = findComponentNode(movedDocument, destination.id); + const toIndex = destinationAfterMove.children.findIndex(child => + child.id === materializedNode.id); + const runtimeToIndex = command.runtimeToIndex ?? runtimeIndexAt( + destinationAfterMove, + toIndex + ); + return result(document, root, MoveNode({ + componentId: document.componentId, + expectedRevision: nextRevision(document), + nodeId: materializedNode.id, + parentId: oldParent.id, + beforeId: oldBeforeId, + runtimeFromIndex: runtimeToIndex, + runtimeToIndex: runtimeFromIndex, + inheritanceTransition: { + kind: ComponentMoveInheritanceTransitionKind.RESTORE, + inheritedNodeId: node.id + } + }), { + kind: ComponentSemanticDeltaKind.NODE_MOVED, + inheritanceTransition: ComponentMoveInheritanceTransitionKind.MATERIALIZE, + inheritedNodeId: node.id, + nodeId: materializedNode.id, + fromParentId: oldParent.id, + fromIndex: oldIndex, + runtimeFromIndex, + toParentId: destination.id, + toIndex, + runtimeToIndex, + beforeId: command.beforeId ?? null, + requiredBindings: command.inheritanceTransition.requiredBindings + }); + } + if (command.inheritanceTransition?.kind === + ComponentMoveInheritanceTransitionKind.RESTORE) { + const inheritedNode = requireNode( + document, + command.inheritanceTransition.inheritedNodeId, + command + ); + if (node.provenance.kind === ComponentNodeProvenanceKind.INHERITED || + inheritedNode.provenance.kind !== ComponentNodeProvenanceKind.INHERITED || + !inheritedNode.provenance.suppressed || destination.id !== oldParent.id && + destination.id !== findComponentParent(document, inheritedNode.id)?.id) { + throw new ComponentCommandError('Inherited restoration move has stale structure', command); + } + const materializedBeforeId = oldParent.children[oldIndex + 1]?.id ?? null; + const removed = removeNodeFromTree(document.root, node.id); + const consolidated = consolidateMaterializedSubtree(node, inheritedNode); + const restoredNode = remapSubtreeReferences( + consolidated.node.with({ + provenance: inheritedNodeProvenance({ + ...inheritedNode.provenance, + suppressed: false, + hasLocalOverrides: inheritedNode.provenance.hasLocalOverrides || + !materializedContentEqualsInherited(node, inheritedNode) + }) + }), + consolidated.idMap + ); + const root = replaceNode( + removed.root, + inheritedNode.id, + () => restoredNode + ); + const restoredParent = findComponentParent( + new ComponentDocument({ ...document, root }), + inheritedNode.id + ); + const toIndex = restoredParent.children.findIndex(child => child.id === inheritedNode.id); + const runtimeToIndex = command.runtimeToIndex ?? runtimeIndexAt(restoredParent, toIndex); + return result(document, root, MoveNode({ + componentId: document.componentId, + expectedRevision: nextRevision(document), + nodeId: inheritedNode.id, + parentId: oldParent.id, + beforeId: materializedBeforeId, + runtimeFromIndex: runtimeToIndex, + runtimeToIndex: runtimeFromIndex, + inheritanceTransition: { + kind: ComponentMoveInheritanceTransitionKind.MATERIALIZE, + node, + requiredBindings: [] + } + }), { + kind: ComponentSemanticDeltaKind.NODE_MOVED, + inheritanceTransition: ComponentMoveInheritanceTransitionKind.RESTORE, + inheritedNodeId: inheritedNode.id, + nodeId: node.id, + consolidatedNodeId: inheritedNode.id, + consolidated: !materializedContentEqualsInherited(node, inheritedNode), + fromParentId: oldParent.id, + fromIndex: oldIndex, + runtimeFromIndex, + toParentId: restoredParent.id, + toIndex, + runtimeToIndex, + beforeId: command.beforeId ?? null + }, remapLayoutModels(document.layoutModels, consolidated.idMap)); + } + const rootWithOrderingAnchors = crossesSourceOwnershipBoundary + ? retargetAddedOrderingAnchors( + document.root, + oldParent.id, + node.id, + oldBeforeId + ) + : document.root; + const removed = removeNodeFromTree(rootWithOrderingAnchors, node.id); + const destinationAfterRemoval = findComponentNode( + new ComponentDocument({ ...document, root: removed.root }), + destination.id + ); + const destinationRequiresAddition = + (document.parentComponent && destination.id === document.root.id) || + destination.provenance.kind === ComponentNodeProvenanceKind.INHERITED || + !!destination.partComponent; + let movedProvenance = command.provenance || node.provenance; + if (!command.provenance && crossesSourceOwnershipBoundary) { + movedProvenance = destinationRequiresAddition + ? addedNodeProvenance() + : localNodeProvenance(); + } + if (movedProvenance.kind === ComponentNodeProvenanceKind.ADDED) { + movedProvenance = addedNodeProvenance(command.orderingName + ? { beforeName: command.orderingName } + : { beforeId: command.beforeId ?? null }); + } + const movedNode = node.with({ provenance: movedProvenance }); + let root = replaceNode(removed.root, destinationAfterRemoval.id, current => + insertChild(current, movedNode, command.beforeId, command)); + for (const restoration of command.orderingRestorations || []) { + root = replaceNode(root, restoration.nodeId, current => current.with({ + provenance: addedNodeProvenance(restoration.beforeName + ? { beforeName: restoration.beforeName } + : { beforeId: restoration.beforeId }) + })); + } + const movedDocument = new ComponentDocument({ ...document, root }); + const destinationAfterMove = findComponentNode(movedDocument, destination.id); + const toIndex = destinationAfterMove.children.findIndex(child => child.id === node.id); + const runtimeToIndex = command.runtimeToIndex ?? runtimeIndexAt(destinationAfterMove, toIndex); + let layoutModels = document.layoutModels; + let parentLayoutReference = null; + if (oldParent.id !== destination.id) { + const withoutReference = withoutParentLayoutReference( + document, + oldParent.id, + node.id + ); + parentLayoutReference = withoutReference.parentLayoutReference; + layoutModels = withParentLayoutReference( + withoutReference.layoutModels, + destination.id, + node.id, + command.parentLayoutReference, + command + ); + } + return result(document, root, MoveNode({ + componentId: document.componentId, + expectedRevision: nextRevision(document), + nodeId: node.id, + parentId: oldParent.id, + beforeId: oldBeforeId, + provenance: node.provenance, + runtimeFromIndex: runtimeToIndex, + runtimeToIndex: runtimeFromIndex, + ...(node.provenance.beforeName + ? { orderingName: node.provenance.beforeName } + : {}), + ...(inverseOrderingRestorations.size + ? { orderingRestorations: [...inverseOrderingRestorations.values()] } + : {}), + ...(parentLayoutReference ? { parentLayoutReference } : {}) + }), { + kind: ComponentSemanticDeltaKind.NODE_MOVED, + nodeId: node.id, + fromParentId: oldParent.id, + fromIndex: oldIndex, + runtimeFromIndex, + toParentId: destination.id, + toIndex, + runtimeToIndex, + beforeId: command.beforeId ?? null, + orderingName: command.orderingName ?? null + }, layoutModels); +} + +function updateSuppression (document, command, suppressed) { + const node = requireNode(document, command.nodeId, command); + if (node.provenance.kind !== ComponentNodeProvenanceKind.INHERITED) { + throw new ComponentCommandError('Only inherited nodes can be suppressed or restored', command); + } + const parent = findComponentParent(document, node.id); + if (!parent) { + throw new ComponentCommandError('The inherited component root cannot be suppressed or restored', command); + } + const nodeIndex = parent.children.indexOf(node); + const runtimeIndex = parent.children.slice(0, nodeIndex).filter(child => + child.provenance.kind !== ComponentNodeProvenanceKind.INHERITED || + !child.provenance.suppressed).length; + if (node.provenance.suppressed === suppressed) { + throw new ComponentCommandError( + `Inherited node ${node.id} is already ${suppressed ? 'suppressed' : 'restored'}`, + command + ); + } + const provenance = inheritedNodeProvenance({ + ...node.provenance, + suppressed + }); + const root = replaceNode(document.root, node.id, current => current.with({ provenance })); + const inverseCommand = suppressed + ? RestoreInheritedNode({ + componentId: document.componentId, + expectedRevision: nextRevision(document), + nodeId: node.id, + parentId: parent.id, + beforeId: node.provenance.beforeId + }) + : SuppressInheritedNode({ + componentId: document.componentId, + expectedRevision: nextRevision(document), + nodeId: node.id + }); + return result(document, root, inverseCommand, { + kind: suppressed + ? ComponentSemanticDeltaKind.NODE_SUPPRESSED + : ComponentSemanticDeltaKind.NODE_RESTORED, + nodeId: node.id, + parentId: parent.id, + index: runtimeIndex + }); +} + +function reduceText (document, command) { + const node = requireNode(document, command.nodeId, command); + const currentEntry = node.properties.textAndAttributes; + if (currentEntry?.kind !== ComponentPropertyKind.EXPLICIT_VALUE) { + throw new ComponentCommandError(`Text editing requires an explicit textAndAttributes value for ${node.id}`, command); + } + const currentText = currentEntry.value; + if (!semanticValuesEqual(currentText, command.operation.before)) { + throw new ComponentCommandError(`Text precondition failed for ${node.id}`, command); + } + const properties = { + ...node.properties, + textAndAttributes: explicitProperty(command.operation.after) + }; + const root = replaceNode(document.root, node.id, current => current.with({ properties })); + return result(document, root, EditText({ + componentId: document.componentId, + expectedRevision: nextRevision(document), + nodeId: node.id, + operation: { + kind: command.operation.kind, + before: command.operation.after, + after: command.operation.before + } + }), { + kind: ComponentSemanticDeltaKind.TEXT_EDITED, + nodeId: node.id, + operation: command.operation + }); +} + +export function reduceComponent (document, command) { + assertComponentDocument(document); + if (command.componentId !== document.componentId) { + throw new ComponentCommandError('Command targets a different component', command); + } + if (command.expectedRevision !== document.revision) { + throw new ComponentCommandError( + `Expected component revision ${command.expectedRevision}, got ${document.revision}`, + command + ); + } + switch (command.kind) { + case ComponentCommandKind.SET_PROPERTY: + return reduceProperty(document, command); + case ComponentCommandKind.CLEAR_PROPERTY_OVERRIDE: + return reduceClearProperty(document, command); + case ComponentCommandKind.RENAME_NODE: + return reduceRename(document, command); + case ComponentCommandKind.INTRODUCE_NODE: + return reduceIntroduce(document, command); + case ComponentCommandKind.MOVE_NODE: + return reduceMove(document, command); + case ComponentCommandKind.REMOVE_NODE: + return reduceRemove(document, command); + case ComponentCommandKind.SUPPRESS_INHERITED_NODE: + return updateSuppression(document, command, true); + case ComponentCommandKind.RESTORE_INHERITED_NODE: + return updateSuppression(document, command, false); + case ComponentCommandKind.SET_MASTER: + return reduceProperty(document, command, 'master', command.entry); + case ComponentCommandKind.EDIT_TEXT: + return reduceText(document, command); + default: + throw new ComponentCommandError(`Unsupported component command ${command.kind}`, command); + } +} diff --git a/lively.ide/components/reconciliation/runtime-node-serializer.js b/lively.ide/components/reconciliation/runtime-node-serializer.js new file mode 100644 index 0000000000..5ea59badf3 --- /dev/null +++ b/lively.ide/components/reconciliation/runtime-node-serializer.js @@ -0,0 +1,537 @@ +import { getValueExpr } from '../helpers.js'; +import { string } from 'lively.lang'; +import { + addedNodeProvenance, + ComponentNode, + ComponentNodeProvenanceKind, + explicitProperty, + findComponentNode, + inheritedNodeProvenance, + localNodeProvenance, + opaqueProperty, + sourceComponentReference +} from './component-document.js'; +import { componentImportBindingsFromExpression } from './import-bindings.js'; + +export const RuntimeNodeSerializationDiagnosticKind = Object.freeze({ + INVALID_SPEC: 'invalid-spec', + UNSUPPORTED_TYPE: 'unsupported-type', + VALUE_UNSERIALIZABLE: 'value-unserializable', + IDENTITY_UNAVAILABLE: 'identity-unavailable' +}); + +function diagnostic (kind, message, details = {}) { + return Object.freeze({ kind, message, ...details }); +} + +function isExplicitValue (value) { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return true; + if (typeof value === 'number') return Number.isFinite(value); + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index++) { + if (!(index in value) || !isExplicitValue(value[index])) return false; + } + return true; + } + return !!value && Object.getPrototypeOf(value) === Object.prototype && + Object.values(value).every(isExplicitValue); +} + +const classNameSymbol = Symbol.for('__LivelyClassName__'); +const moduleMetaSymbol = Symbol.for('lively-module-meta'); + +function runtimeTypeName (type) { + if (typeof type !== 'function') return null; + return type[classNameSymbol] || type.name || null; +} + +function isDefaultMorphType (type) { + if (type === undefined || type === null) return true; + const name = typeof type === 'string' + ? type + : runtimeTypeName(type); + return name === 'Morph'; +} + +function moduleIdFromClassMeta (meta) { + const packageName = meta?.package?.name; + const pathInPackage = meta?.pathInPackage; + if (typeof packageName !== 'string' || !packageName || + typeof pathInPackage !== 'string' || !pathInPackage) return null; + return `${packageName.replace(/\/$/, '')}/${pathInPackage.replace(/^\//, '')}`; +} + +function runtimeTypeProjection (type, name) { + if (isDefaultMorphType(type)) { + return Object.freeze({ supported: true, expression: null, bindings: Object.freeze({}) }); + } + const typeName = runtimeTypeName(type); + const moduleId = moduleIdFromClassMeta(type?.[moduleMetaSymbol]); + if (!typeName || !moduleId) { + return Object.freeze({ + supported: false, + diagnostic: diagnostic( + RuntimeNodeSerializationDiagnosticKind.UNSUPPORTED_TYPE, + `Introduced morph ${name} uses a constructor without source module metadata`, + { name, typeName } + ) + }); + } + return Object.freeze({ + supported: true, + expression: typeName, + bindings: Object.freeze({ [moduleId]: Object.freeze([typeName]) }) + }); +} + +function runtimePartProjection (morph) { + const master = morph?.master; + const directParent = master?.parent; + const directParentMeta = directParent?.[moduleMetaSymbol]; + const partPolicy = Array.isArray(directParentMeta?.path) && + directParentMeta.path.length === 0 + ? directParent + : [ + master?._autoMaster, + directParent?._autoMaster, + directParent?.parent, + directParent?._parent + ] + .find(policy => { + const meta = policy?.[moduleMetaSymbol]; + return meta && Array.isArray(meta.path) && meta.path.length === 0; + }); + const meta = partPolicy?.[moduleMetaSymbol]; + if (!meta || + typeof meta.exportedName !== 'string' || !meta.exportedName || + typeof meta.moduleId !== 'string' || !meta.moduleId || + !Array.isArray(meta.path) || meta.path.length !== 0) return null; + return Object.freeze({ + reference: sourceComponentReference(meta.exportedName), + policy: partPolicy, + bindings: Object.freeze({ + [meta.moduleId]: Object.freeze([meta.exportedName]), + 'lively.morphic': Object.freeze(['part']) + }) + }); +} + +function runtimeMasterDescription (master) { + const describe = policy => { + const meta = policy?.[moduleMetaSymbol]; + return { + type: policy?.constructor?.name || typeof policy, + exportedName: meta?.exportedName || null, + moduleId: meta?.moduleId || null, + path: Array.isArray(meta?.path) ? meta.path : null + }; + }; + return { + master: describe(master), + parent: describe(master?.parent), + privateParent: describe(master?._parent), + auto: describe(master?._autoMaster), + parentAuto: describe(master?.parent?._autoMaster), + parentPrivateParent: describe(master?.parent?._parent), + grandparent: describe(master?.parent?.parent) + }; +} + +function runtimePartOverrideSpec ( + morph, + runtimeSpec, + materializeInheritedChildren, + partPolicy = null +) { + const overrideSpec = morph?.master?._originalSpec || morph?.master?.spec; + if (!overrideSpec || typeof overrideSpec !== 'object' || Array.isArray(overrideSpec)) { + const partSpec = { ...runtimeSpec }; + delete partSpec.master; + return partSpec; + } + const localSubmorphs = Array.isArray(overrideSpec.submorphs) + ? overrideSpec.submorphs + : []; + const localOverrides = localSubmorphs.map(localSpec => { + const normalized = normalizedRuntimeSpec(localSpec); + return normalized.added + ? localSpec + : { ...normalized.spec, __projectionalInheritedOverride__: true }; + }); + const materializedChildren = morph?.submorphs?.length + ? morph.submorphs + : partPolicy?.spec?.submorphs || []; + const submorphs = materializeInheritedChildren + ? materializedChildren.map((child, index) => { + const childName = child?.name || + normalizedRuntimeSpec(child).spec?.name; + const localSpec = localSubmorphs.find(spec => + normalizedRuntimeSpec(spec).spec?.name === childName); + if (!localSpec) { + const policySpec = typeof child?.spec === 'function' + ? child.spec() + : child && !child.owner && !child.id + ? normalizedRuntimeSpec(child).spec + : null; + return policySpec && childName + ? { ...policySpec, name: childName, __projectionalInheritedOverride__: true } + : { name: childName || `submorph ${index + 1}`, __projectionalInheritedOverride__: true }; + } + const normalized = normalizedRuntimeSpec(localSpec); + return normalized.added + ? localSpec + : { ...normalized.spec, __projectionalInheritedOverride__: true }; + }) + : localOverrides; + return { + ...overrideSpec, + name: typeof overrideSpec.name === 'string' && overrideSpec.name + ? overrideSpec.name + : runtimeSpec.name, + submorphs + }; +} + +function normalizedRuntimeSpec (spec) { + let added = false; + let beforeName = null; + while (spec && typeof spec === 'object') { + if (spec.COMMAND === 'add') { + added = true; + beforeName = typeof spec.before === 'string' && spec.before ? spec.before : null; + spec = spec.props; + continue; + } + if (spec.isPolicy) { + spec = spec._originalSpec || spec.spec; + continue; + } + break; + } + return { spec, added, beforeName }; +} + +function runtimeChildForSpec (morph, childSpec, index) { + const children = morph?.submorphs || []; + const { spec } = normalizedRuntimeSpec(childSpec); + const name = spec?.name; + return children.find(child => child?.name === name) || children[index]; +} + +function mergeBindings (target, additions = {}) { + for (const [moduleId, bindings] of Object.entries(additions)) { + target[moduleId] = Array.from(new Set([...(target[moduleId] || []), ...bindings])); + } +} + +function childNodeId (document, parentId, index) { + const candidateFor = candidateIndex => parentId === document.root.id + ? `${document.componentId}:node:${candidateIndex}` + : `${parentId}.${candidateIndex}`; + let candidateIndex = index; + let candidate = candidateFor(candidateIndex); + while (findComponentNode(document, candidate)) { + candidate = candidateFor(++candidateIndex); + } + return candidate; +} + +function availableSiblingName (document, parentId, requestedName, allocateName) { + const siblingNames = new Set(findComponentNode(document, parentId)?.children + .map(child => child.name) || []); + let candidate = requestedName; + while (siblingNames.has(candidate)) candidate = string.incName(candidate); + candidate = allocateName(candidate); + if (typeof candidate !== 'string' || !candidate) { + throw new Error('Introduced node name allocation requires a non-empty name'); + } + while (siblingNames.has(candidate)) candidate = string.incName(candidate); + return candidate; +} + +function inheritedRuntimeNode (node, ownerId, path = []) { + const id = `${ownerId}:inherited:${path.join('.')}`; + return new ComponentNode({ + id, + name: node.name, + provenance: inheritedNodeProvenance({ + suppressed: node.provenance.kind === ComponentNodeProvenanceKind.INHERITED && + node.provenance.suppressed, + baseName: node.provenance.baseName || node.name + }), + partComponent: node.partComponent, + typeExpression: node.typeExpression, + properties: node.properties, + children: node.children.map((child, index) => + inheritedRuntimeNode(child, ownerId, [...path, index])) + }); +} + +function mergeResolvedPartChildren ( + baseChildren, + localChildren, + ownerId, + cloneBase = true +) { + const children = cloneBase + ? baseChildren.map((child, index) => + inheritedRuntimeNode(child, ownerId, [index])) + : baseChildren.slice(); + for (const local of localChildren) { + if (local.provenance.kind === ComponentNodeProvenanceKind.ADDED) { + const beforeName = local.provenance.beforeName; + const index = beforeName + ? children.findIndex(child => child.name === beforeName) + : children.length; + children.splice(index < 0 ? children.length : index, 0, local); + continue; + } + const baseName = local.provenance.baseName || local.name; + const index = children.findIndex(child => + child.name === baseName || child.provenance.baseName === baseName); + if (index < 0) { + children.push(local); + continue; + } + const inherited = children[index]; + children[index] = inherited.with({ + name: local.name, + provenance: inheritedNodeProvenance({ + hasLocalOverrides: true, + baseName: inherited.provenance.baseName || inherited.name + }), + partComponent: local.partComponent || inherited.partComponent, + typeExpression: local.typeExpression || inherited.typeExpression, + properties: { ...inherited.properties, ...local.properties }, + children: mergeResolvedPartChildren( + inherited.children, + local.children, + inherited.id, + false + ) + }); + } + return children; +} + +function nodeFromSpec ({ + spec, + morph, + nodeId, + bindings, + diagnostics, + resolveComponentDocument, + partComponent = null, + insidePartOverride = false, + materializePartSubtree = false +}) { + const normalized = normalizedRuntimeSpec(spec); + spec = normalized.spec; + if (!spec || typeof spec !== 'object' || Array.isArray(spec) || + typeof spec.name !== 'string' || !spec.name) { + diagnostics.push(diagnostic( + RuntimeNodeSerializationDiagnosticKind.INVALID_SPEC, + 'Introduced morph specs require a non-empty static name' + )); + return null; + } + if (normalized.added) mergeBindings(bindings, { 'lively.morphic': ['add'] }); + const partProjection = runtimePartProjection(morph) || (partComponent + ? Object.freeze({ + reference: partComponent, + bindings: Object.freeze({ 'lively.morphic': Object.freeze(['part']) }) + }) + : null); + const projectsPartReference = !!partProjection && + (!insidePartOverride || normalized.added); + const typeProjection = runtimeTypeProjection(spec.type, spec.name); + if (!partProjection && !typeProjection.supported) { + diagnostics.push(typeProjection.diagnostic); + return null; + } + if (projectsPartReference) mergeBindings(bindings, partProjection.bindings); + else if (!partProjection) mergeBindings(bindings, typeProjection.bindings); + const resolvedPart = partProjection && resolveComponentDocument?.({ + expression: partProjection.reference.expression + }); + spec = partProjection + ? runtimePartOverrideSpec( + morph, + spec, + materializePartSubtree || !resolvedPart, + partProjection.policy + ) + : spec; + const submorphs = spec.submorphs === undefined ? [] : spec.submorphs; + if (!Array.isArray(submorphs)) { + diagnostics.push(diagnostic( + RuntimeNodeSerializationDiagnosticKind.INVALID_SPEC, + `Introduced morph ${spec.name} has invalid submorphs` + )); + return null; + } + + const properties = {}; + for (const [property, value] of Object.entries(spec)) { + if (['name', 'type', 'submorphs', '__wasAddedToDerived__', + '__projectionalInheritedOverride__'].includes(property)) continue; + if (isExplicitValue(value)) { + properties[property] = explicitProperty(value); + continue; + } + let expression; + try { + expression = getValueExpr(property, value); + } catch (error) { + diagnostics.push(diagnostic( + RuntimeNodeSerializationDiagnosticKind.VALUE_UNSERIALIZABLE, + `Cannot serialize introduced morph property ${spec.name}.${property}`, + { property, error } + )); + return null; + } + if (typeof expression?.__expr__ !== 'string' || !expression.__expr__.trim()) { + diagnostics.push(diagnostic( + RuntimeNodeSerializationDiagnosticKind.VALUE_UNSERIALIZABLE, + `Cannot serialize introduced morph property ${spec.name}.${property}` + + (property === 'master' + ? ` (${JSON.stringify(runtimeMasterDescription(morph?.master))})` + : ''), + { property } + )); + return null; + } + properties[property] = opaqueProperty(expression.__expr__); + mergeBindings(bindings, expression.bindings); + } + + const children = []; + const provenance = normalized.added + ? addedNodeProvenance({ beforeName: normalized.beforeName }) + : spec.__projectionalInheritedOverride__ || insidePartOverride + ? inheritedNodeProvenance({ + hasLocalOverrides: true, + baseName: spec.name + }) + : localNodeProvenance(); + for (const [index, childSpec] of submorphs.entries()) { + const childId = `${nodeId}.${index}`; + const child = nodeFromSpec({ + spec: childSpec, + morph: runtimeChildForSpec(morph, childSpec, index), + nodeId: childId, + bindings, + diagnostics, + resolveComponentDocument, + materializePartSubtree, + insidePartOverride: !!partProjection || + (insidePartOverride && provenance.kind !== ComponentNodeProvenanceKind.ADDED) + }); + if (!child) return null; + children.push(child); + } + if (projectsPartReference && resolvedPart && !materializePartSubtree) { + children.splice(0, children.length, ...mergeResolvedPartChildren( + resolvedPart.root.children, + children, + nodeId + )); + } + if (partProjection || insidePartOverride) { + for (let index = 0; index < children.length - 1; index++) { + const child = children[index]; + if (child.provenance.kind !== ComponentNodeProvenanceKind.ADDED || + child.provenance.beforeId || child.provenance.beforeName) continue; + children[index] = child.with({ + provenance: addedNodeProvenance({ beforeId: children[index + 1].id }) + }); + } + } + return new ComponentNode({ + id: nodeId, + name: spec.name, + provenance, + partComponent: projectsPartReference + ? partProjection.reference + : null, + typeExpression: partProjection ? null : typeProjection.expression, + properties, + children + }); +} + +export function serializeRuntimeComponentNode ({ + document, + parentId, + index, + morph, + partComponent = null, + materializePartSubtree = false, + allocateName = name => name +}) { + const nodeId = childNodeId(document, parentId, index); + if (!nodeId) { + return Object.freeze({ + supported: false, + node: null, + bindings: Object.freeze({}), + requiredBindings: Object.freeze([]), + diagnostics: Object.freeze([diagnostic( + RuntimeNodeSerializationDiagnosticKind.IDENTITY_UNAVAILABLE, + `Cannot allocate a source-path identity below ${parentId}` + )]) + }); + } + let spec; + try { + spec = morph?.spec?.(); + } catch (error) { + return Object.freeze({ + supported: false, + node: null, + bindings: Object.freeze({}), + requiredBindings: Object.freeze([]), + diagnostics: Object.freeze([diagnostic( + RuntimeNodeSerializationDiagnosticKind.INVALID_SPEC, + 'The introduced runtime morph could not produce a component spec', + { error } + )]) + }); + } + const diagnostics = []; + const bindings = {}; + let node = nodeFromSpec({ + spec, + morph, + nodeId, + bindings, + diagnostics, + resolveComponentDocument: document.sourceMetadata.resolveComponentDocument, + partComponent, + materializePartSubtree + }); + let runtimeRename = null; + if (node) { + const allocatedName = availableSiblingName( + document, + parentId, + node.name, + allocateName + ); + if (allocatedName !== node.name) { + runtimeRename = Object.freeze({ before: node.name, after: allocatedName }); + node = node.with({ name: allocatedName }); + } + } + const frozenBindings = Object.freeze(Object.fromEntries( + Object.entries(bindings).map(([moduleId, names]) => [moduleId, Object.freeze(names)]) + )); + return Object.freeze({ + supported: diagnostics.length === 0 && !!node, + node, + runtimeRename, + bindings: frozenBindings, + requiredBindings: Object.freeze(componentImportBindingsFromExpression(frozenBindings)), + diagnostics: Object.freeze(diagnostics) + }); +} diff --git a/lively.ide/components/reconciliation/runtime-projector.js b/lively.ide/components/reconciliation/runtime-projector.js new file mode 100644 index 0000000000..73dcaf61a9 --- /dev/null +++ b/lively.ide/components/reconciliation/runtime-projector.js @@ -0,0 +1,450 @@ +import { + MoveMorph, + MorphicChangeSet, + MorphicValueSemantics, + SetMorphProperty, + attachedMorph, + detachedMorph +} from 'lively.morphic/changes/index.js'; +import { + ComponentDocument, + ComponentPropertyKind, + findComponentNode +} from './component-document.js'; +import { ComponentSemanticDeltaKind } from './reducer.js'; + +export const ComponentRuntimeProjectionDiagnosticKind = Object.freeze({ + UNSUPPORTED_DELTA: 'unsupported-delta', + RUNTIME_TARGET_UNRESOLVED: 'runtime-target-unresolved', + RUNTIME_VALUE_UNAVAILABLE: 'runtime-value-unavailable' +}); + +function diagnostic (kind, message, details = {}) { + return Object.freeze({ kind, message, ...details }); +} + +function unavailableValue () { + return Object.freeze({ available: false }); +} + +function explicitEntryValue (entry) { + return entry?.kind === ComponentPropertyKind.EXPLICIT_VALUE + ? Object.freeze({ available: true, value: entry.value }) + : unavailableValue(); +} + +function resolvedRuntimeValue (resolveRuntimeValue, spec, fallbackEntry) { + const resolved = resolveRuntimeValue?.(spec); + if (resolved?.available) return Object.freeze({ available: true, value: resolved.value }); + return explicitEntryValue(fallbackEntry); +} + +function unsupportedResult (diagnostics) { + return Object.freeze({ + supported: false, + changeSet: null, + inverseChangeSet: null, + diagnostics: Object.freeze(diagnostics) + }); +} + +function effectiveLayoutSnapshot (layout) { + if (typeof layout?.getSpec !== 'function') return layout; + const spec = layout.getSpec(); + if (!layout.container || !Array.isArray(layout.resizePolicies)) return spec; + const resizePolicies = layout.resizePolicies + .filter(([, policy]) => + policy.width !== 'fixed' || policy.height !== 'fixed') + .map(([name, policy]) => [name, { ...policy }]) + .sort(([left], [right]) => left.localeCompare(right)); + const effectiveSpec = { ...spec }; + if (resizePolicies.length) effectiveSpec.resizePolicies = resizePolicies; + else delete effectiveSpec.resizePolicies; + return effectiveSpec; +} + +export function projectComponentRuntime ({ + beforeDocument, + reduction, + changeSetId, + resolveRuntimeTargetId = nodeId => nodeId, + resolveRuntimeValue, + runtimeRename = null, + resolveRuntimeLayout +}) { + if (!(beforeDocument instanceof ComponentDocument)) { + throw new Error('Runtime projection requires the previous ComponentDocument'); + } + if (!(reduction?.document instanceof ComponentDocument)) { + throw new Error('Runtime projection requires a component reduction result'); + } + if (typeof changeSetId !== 'string' || !changeSetId) { + throw new Error('Runtime projection requires a changeSetId'); + } + + const { semanticDelta } = reduction; + const diagnostics = []; + const layoutProjection = [ + ComponentSemanticDeltaKind.NODE_RENAMED, + ComponentSemanticDeltaKind.NODE_REMOVED, + ComponentSemanticDeltaKind.NODE_MOVED + ].includes(semanticDelta.kind) + ? resolveRuntimeLayout?.(Object.freeze({ + semanticDelta, + beforeDocument, + afterDocument: reduction.document + })) || null + : null; + if (layoutProjection && ( + typeof layoutProjection.ownerId !== 'string' || !layoutProjection.ownerId || + !Object.prototype.hasOwnProperty.call(layoutProjection, 'before') || + !Object.prototype.hasOwnProperty.call(layoutProjection, 'after') + )) { + return unsupportedResult([diagnostic( + ComponentRuntimeProjectionDiagnosticKind.RUNTIME_VALUE_UNAVAILABLE, + 'Runtime owner layout projection is incomplete', + { nodeId: semanticDelta.nodeId } + )]); + } + const withLayoutOperation = operation => { + if (!layoutProjection) return [operation]; + const snapshotsLayout = typeof layoutProjection.before?.getSpec === 'function' && + typeof layoutProjection.after?.getSpec === 'function' && + typeof layoutProjection.after?.constructor === 'function'; + return [new SetMorphProperty({ + targetId: layoutProjection.ownerId, + property: 'layout', + before: layoutProjection.before, + after: layoutProjection.after, + ...(snapshotsLayout + ? { + valueSemantics: MorphicValueSemantics.SNAPSHOT, + snapshotValue: effectiveLayoutSnapshot, + materializeValue: spec => + new layoutProjection.after.constructor(spec) + } + : {}), + metadata: { + origin: 'runtime-projection', + reconcileChanges: false, + componentId: beforeDocument.componentId, + fromRevision: beforeDocument.revision, + toRevision: reduction.document.revision, + semanticDeltaKind: semanticDelta.kind, + applyWhenAdopting: layoutProjection.applyWhenAdopting === true + } + }), operation]; + }; + const withRuntimeRename = operations => runtimeRename + ? [new SetMorphProperty({ + targetId: resolveRuntimeTargetId(semanticDelta.nodeId), + property: 'name', + before: runtimeRename.before, + after: runtimeRename.after, + metadata: { + origin: 'runtime-projection', + reconcileChanges: false, + componentId: beforeDocument.componentId, + fromRevision: beforeDocument.revision, + toRevision: reduction.document.revision, + semanticDeltaKind: semanticDelta.kind, + applyWhenAdopting: true, + acceptAlreadyApplied: true + } + }), ...operations] + : operations; + const runtimeTargetId = resolveRuntimeTargetId(semanticDelta.nodeId); + if (typeof runtimeTargetId !== 'string' || !runtimeTargetId) { + diagnostics.push(diagnostic( + ComponentRuntimeProjectionDiagnosticKind.RUNTIME_TARGET_UNRESOLVED, + `No runtime target is available for component node ${semanticDelta.nodeId}`, + { nodeId: semanticDelta.nodeId } + )); + return unsupportedResult(diagnostics); + } + + if (semanticDelta.kind === ComponentSemanticDeltaKind.NODE_INTRODUCED) { + const runtimeParentId = resolveRuntimeTargetId(semanticDelta.parentId); + if (typeof runtimeParentId !== 'string' || !runtimeParentId) { + return unsupportedResult([diagnostic( + ComponentRuntimeProjectionDiagnosticKind.RUNTIME_TARGET_UNRESOLVED, + `No runtime parent is available for component node ${semanticDelta.parentId}`, + { nodeId: semanticDelta.parentId } + )]); + } + const operation = new MoveMorph({ + morphId: runtimeTargetId, + from: detachedMorph(), + to: attachedMorph({ + ownerId: runtimeParentId, + index: semanticDelta.runtimeIndex ?? semanticDelta.index + }), + metadata: { + origin: 'runtime-projection', + reconcileChanges: false, + componentId: beforeDocument.componentId, + fromRevision: beforeDocument.revision, + toRevision: reduction.document.revision, + semanticDeltaKind: semanticDelta.kind + } + }); + const changeSet = new MorphicChangeSet({ + id: changeSetId, + label: 'project component node introduction', + origin: 'runtime-projection', + undoable: false, + operations: withRuntimeRename(withLayoutOperation(operation)), + metadata: operation.metadata + }); + return Object.freeze({ + supported: true, + changeSet, + inverseChangeSet: changeSet.invert({ + id: `${changeSetId}:inverse`, + origin: 'runtime-projection', + metadata: { rollbackOf: changeSetId } + }), + diagnostics: Object.freeze([]) + }); + } + + if (semanticDelta.kind === ComponentSemanticDeltaKind.NODE_REMOVED) { + const runtimeParentId = resolveRuntimeTargetId(semanticDelta.parentId); + if (typeof runtimeParentId !== 'string' || !runtimeParentId) { + return unsupportedResult([diagnostic( + ComponentRuntimeProjectionDiagnosticKind.RUNTIME_TARGET_UNRESOLVED, + `No runtime parent is available for component node ${semanticDelta.parentId}`, + { nodeId: semanticDelta.parentId } + )]); + } + const operation = new MoveMorph({ + morphId: runtimeTargetId, + from: attachedMorph({ + ownerId: runtimeParentId, + index: semanticDelta.runtimeIndex ?? semanticDelta.index + }), + to: detachedMorph(), + metadata: { + origin: 'runtime-projection', + reconcileChanges: false, + componentId: beforeDocument.componentId, + fromRevision: beforeDocument.revision, + toRevision: reduction.document.revision, + semanticDeltaKind: semanticDelta.kind + } + }); + const changeSet = new MorphicChangeSet({ + id: changeSetId, + label: 'project component node removal', + origin: 'runtime-projection', + undoable: false, + operations: withLayoutOperation(operation), + metadata: operation.metadata + }); + return Object.freeze({ + supported: true, + changeSet, + inverseChangeSet: changeSet.invert({ + id: `${changeSetId}:inverse`, + origin: 'runtime-projection', + metadata: { rollbackOf: changeSetId } + }), + diagnostics: Object.freeze([]) + }); + } + + if (semanticDelta.kind === ComponentSemanticDeltaKind.NODE_SUPPRESSED || + semanticDelta.kind === ComponentSemanticDeltaKind.NODE_RESTORED) { + const runtimeParentId = resolveRuntimeTargetId(semanticDelta.parentId); + if (typeof runtimeParentId !== 'string' || !runtimeParentId) { + return unsupportedResult([diagnostic( + ComponentRuntimeProjectionDiagnosticKind.RUNTIME_TARGET_UNRESOLVED, + `No runtime parent is available for inherited node ${semanticDelta.nodeId}`, + { nodeId: semanticDelta.parentId } + )]); + } + const suppressing = semanticDelta.kind === ComponentSemanticDeltaKind.NODE_SUPPRESSED; + const attached = attachedMorph({ ownerId: runtimeParentId, index: semanticDelta.index }); + const operation = new MoveMorph({ + morphId: runtimeTargetId, + from: suppressing ? attached : detachedMorph(), + to: suppressing ? detachedMorph() : attached, + metadata: { + origin: 'runtime-projection', + reconcileChanges: false, + componentId: beforeDocument.componentId, + fromRevision: beforeDocument.revision, + toRevision: reduction.document.revision, + semanticDeltaKind: semanticDelta.kind + } + }); + const changeSet = new MorphicChangeSet({ + id: changeSetId, + label: suppressing + ? 'project inherited component node suppression' + : 'project inherited component node restoration', + origin: 'runtime-projection', + undoable: false, + operations: withLayoutOperation(operation), + metadata: operation.metadata + }); + return Object.freeze({ + supported: true, + changeSet, + inverseChangeSet: changeSet.invert({ + id: `${changeSetId}:inverse`, + origin: 'runtime-projection', + metadata: { rollbackOf: changeSetId } + }), + diagnostics: Object.freeze([]) + }); + } + + if (semanticDelta.kind === ComponentSemanticDeltaKind.NODE_MOVED) { + const runtimeFromParentId = resolveRuntimeTargetId(semanticDelta.fromParentId); + const runtimeToParentId = resolveRuntimeTargetId(semanticDelta.toParentId); + if (typeof runtimeFromParentId !== 'string' || !runtimeFromParentId || + typeof runtimeToParentId !== 'string' || !runtimeToParentId) { + return unsupportedResult([diagnostic( + ComponentRuntimeProjectionDiagnosticKind.RUNTIME_TARGET_UNRESOLVED, + `No runtime parent is available for component node ${semanticDelta.nodeId}`, + { + fromParentId: semanticDelta.fromParentId, + toParentId: semanticDelta.toParentId + } + )]); + } + const operation = new MoveMorph({ + morphId: runtimeTargetId, + from: attachedMorph({ + ownerId: runtimeFromParentId, + index: semanticDelta.runtimeFromIndex ?? semanticDelta.fromIndex + }), + to: attachedMorph({ + ownerId: runtimeToParentId, + index: semanticDelta.runtimeToIndex ?? semanticDelta.toIndex + }), + metadata: { + origin: 'runtime-projection', + reconcileChanges: false, + componentId: beforeDocument.componentId, + fromRevision: beforeDocument.revision, + toRevision: reduction.document.revision, + semanticDeltaKind: semanticDelta.kind + } + }); + const changeSet = new MorphicChangeSet({ + id: changeSetId, + label: 'project component node movement', + origin: 'runtime-projection', + undoable: false, + operations: withRuntimeRename(withLayoutOperation(operation)), + metadata: operation.metadata + }); + return Object.freeze({ + supported: true, + changeSet, + inverseChangeSet: changeSet.invert({ + id: `${changeSetId}:inverse`, + origin: 'runtime-projection', + metadata: { rollbackOf: changeSetId } + }), + diagnostics: Object.freeze([]) + }); + } + + let property; + let before; + let after; + if (semanticDelta.kind === ComponentSemanticDeltaKind.NODE_RENAMED) { + property = 'name'; + before = Object.freeze({ available: true, value: semanticDelta.before }); + after = Object.freeze({ available: true, value: semanticDelta.after }); + } else if ( + semanticDelta.kind === ComponentSemanticDeltaKind.PROPERTY_SET || + semanticDelta.kind === ComponentSemanticDeltaKind.PROPERTY_CLEARED || + semanticDelta.kind === ComponentSemanticDeltaKind.TEXT_EDITED + ) { + property = semanticDelta.kind === ComponentSemanticDeltaKind.TEXT_EDITED + ? 'textAndAttributes' + : semanticDelta.property; + const beforeNode = findComponentNode(beforeDocument, semanticDelta.nodeId); + const afterNode = findComponentNode(reduction.document, semanticDelta.nodeId); + const valueSpec = phase => Object.freeze({ + phase, + nodeId: semanticDelta.nodeId, + runtimeTargetId, + property, + semanticDelta, + entry: phase === 'before' + ? beforeNode?.properties[property] + : afterNode?.properties[property] + }); + before = resolvedRuntimeValue( + resolveRuntimeValue, + valueSpec('before'), + beforeNode?.properties[property] + ); + after = resolvedRuntimeValue( + resolveRuntimeValue, + valueSpec('after'), + afterNode?.properties[property] + ); + } else { + diagnostics.push(diagnostic( + ComponentRuntimeProjectionDiagnosticKind.UNSUPPORTED_DELTA, + `Scalar runtime projection does not support ${semanticDelta.kind}`, + { semanticDeltaKind: semanticDelta.kind } + )); + return unsupportedResult(diagnostics); + } + + if (!before.available || !after.available) { + const phases = [!before.available && 'before', !after.available && 'after'].filter(Boolean); + diagnostics.push(diagnostic( + ComponentRuntimeProjectionDiagnosticKind.RUNTIME_VALUE_UNAVAILABLE, + `Runtime ${phases.join(' and ')} value unavailable for ${semanticDelta.nodeId}.${property}`, + { nodeId: semanticDelta.nodeId, property, phases: Object.freeze(phases) } + )); + return unsupportedResult(diagnostics); + } + + const operation = new SetMorphProperty({ + targetId: runtimeTargetId, + property, + before: before.value, + after: after.value, + metadata: { + origin: 'runtime-projection', + reconcileChanges: false, + componentId: beforeDocument.componentId, + fromRevision: beforeDocument.revision, + toRevision: reduction.document.revision, + semanticDeltaKind: semanticDelta.kind + } + }); + const changeSet = new MorphicChangeSet({ + id: changeSetId, + label: `project component ${property}`, + origin: 'runtime-projection', + undoable: false, + operations: withLayoutOperation(operation), + metadata: { + reconcileChanges: false, + componentId: beforeDocument.componentId, + fromRevision: beforeDocument.revision, + toRevision: reduction.document.revision + } + }); + const inverseChangeSet = changeSet.invert({ + id: `${changeSetId}:inverse`, + origin: 'runtime-projection', + metadata: { rollbackOf: changeSetId } + }); + return Object.freeze({ + supported: true, + changeSet, + inverseChangeSet, + diagnostics: Object.freeze([]) + }); +} diff --git a/lively.ide/components/reconciliation/shadow-projection.js b/lively.ide/components/reconciliation/shadow-projection.js new file mode 100644 index 0000000000..6e6a1df9fd --- /dev/null +++ b/lively.ide/components/reconciliation/shadow-projection.js @@ -0,0 +1,738 @@ +import { MorphicChangeSet } from 'lively.morphic/changes/index.js'; +import { + addedNodeProvenance, + ComponentDocument, + ComponentNodeProvenanceKind, + ComponentPropertyKind, + findComponentNode, + findComponentParent, + inheritedNodeProvenance +} from './component-document.js'; +import { + ComponentMoveInheritanceTransitionKind, + ComponentTextEditKind, + EditText, + IntroduceNode, + MoveNode, + RemoveNode, + RenameNode, + RestoreInheritedNode, + SetMaster, + SetOpaqueProperty, + SetProperty, + SuppressInheritedNode +} from './commands.js'; +import { componentImportBindingsFromExpression } from './import-bindings.js'; +import { ComponentBridgeCommandKind } from './morphic-change-set-adapter.js'; +import { reduceComponent } from './reducer.js'; +import { projectComponentRuntime } from './runtime-projector.js'; +import { parseComponentSource } from './source-adapter.js'; +import { + componentDocumentsSemanticallyEqual, + projectComponentSource +} from './source-projector.js'; + +export const ShadowProjectionDiagnosticKind = Object.freeze({ + SOURCE_UNAVAILABLE: 'source-unavailable', + SOURCE_UNSUPPORTED: 'source-unsupported', + COMMAND_UNSUPPORTED: 'command-unsupported', + NODE_ID_UNRESOLVED: 'node-id-unresolved', + VALUE_EXPRESSION_UNAVAILABLE: 'value-expression-unavailable', + REDUCTION_FAILED: 'reduction-failed', + SOURCE_PROJECTION_FAILED: 'source-projection-failed', + RUNTIME_PROJECTION_FAILED: 'runtime-projection-failed' +}); + +export const ShadowProjectionComparisonKind = Object.freeze({ + MATCH: 'match', + SEMANTIC_MISMATCH: 'semantic-mismatch', + CURRENT_SOURCE_UNSUPPORTED: 'current-source-unsupported', + PROJECTION_COMPARISON_FAILED: 'projection-comparison-failed' +}); + +function diagnostic (kind, message, details = {}) { + return Object.freeze({ kind, message, ...details }); +} + +function runtimeVisibleChildren (parent, excludingNodeId = null) { + return parent.children.filter(child => + child.id !== excludingNodeId && + (child.provenance.kind !== ComponentNodeProvenanceKind.INHERITED || + !child.provenance.suppressed)); +} + +function preserveMaterializedDescendantProvenance (serializedNode, semanticNode) { + const semanticChildren = new Map(semanticNode.children.map(child => [child.name, child])); + const serializedChildren = new Map(serializedNode.children.map(child => [child.name, child])); + const candidateChildren = semanticNode.children.map((semanticChild, index) => + serializedChildren.get(semanticChild.name) || semanticChild.with({ + id: `${serializedNode.id}.${index}`, + children: [] + })); + for (const child of serializedNode.children) { + if (!semanticChildren.has(child.name)) candidateChildren.push(child); + } + const children = candidateChildren.map(child => { + const semanticChild = semanticChildren.get(child.name); + if (!semanticChild) return child; + let projected = preserveMaterializedDescendantProvenance(child, semanticChild); + projected = projected.with({ + typeExpression: semanticChild.typeExpression || projected.typeExpression, + properties: semanticChild.properties, + partComponent: semanticChild.partComponent || projected.partComponent + }); + if (semanticChild.provenance.kind === ComponentNodeProvenanceKind.ADDED) { + const semanticBefore = semanticChild.provenance.beforeId && + semanticNode.children.find(candidate => + candidate.id === semanticChild.provenance.beforeId); + const beforeName = semanticChild.provenance.beforeName || semanticBefore?.name || null; + const projectedBefore = beforeName && + candidateChildren.find(candidate => candidate.name === beforeName); + projected = projected.with({ + provenance: addedNodeProvenance(projectedBefore + ? { beforeId: projectedBefore.id } + : beforeName + ? { beforeName } + : {}) + }); + } else if (semanticChild.provenance.kind === ComponentNodeProvenanceKind.INHERITED) { + projected = projected.with({ + provenance: inheritedNodeProvenance({ + ...semanticChild.provenance, + hasLocalOverrides: true, + beforeId: null + }) + }); + } + return projected; + }); + return serializedNode.with({ + typeExpression: semanticNode.typeExpression || serializedNode.typeExpression, + properties: semanticNode.properties, + partComponent: semanticNode.partComponent || serializedNode.partComponent, + children + }); +} + +function isSemanticValue (value) { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return true; + if (typeof value === 'number') return Number.isFinite(value); + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index++) { + if (!(index in value) || !isSemanticValue(value[index])) return false; + } + return true; + } + return !!value && Object.getPrototypeOf(value) === Object.prototype && + Object.values(value).every(isSemanticValue); +} + +function serializedBindingsForNode (serialized, node) { + if (node.provenance.kind !== ComponentNodeProvenanceKind.ADDED) { + return Object.freeze({ + bindings: serialized.bindings, + requiredBindings: serialized.requiredBindings + }); + } + const bindings = { + ...serialized.bindings, + 'lively.morphic': Array.from(new Set([ + ...(serialized.bindings['lively.morphic'] || []), + 'add' + ])) + }; + return Object.freeze({ + bindings: Object.freeze(bindings), + requiredBindings: componentImportBindingsFromExpression(bindings) + }); +} + +function componentCommandFor ({ + document, + bridgeCommand, + nodeId, + destinationParentId, + runtimeNodeNameFor, + runtimeOrderingNameFor, + valueExpressionFor, + introducedNodeFor +}) { + const commandSpec = { + componentId: document.componentId, + expectedRevision: document.revision, + nodeId + }; + if (bridgeCommand.kind === ComponentBridgeCommandKind.RENAME_NODE) { + return { command: RenameNode({ ...commandSpec, name: bridgeCommand.name }), bindings: {} }; + } + if (bridgeCommand.kind === ComponentBridgeCommandKind.EDIT_TEXT) { + const entry = findComponentNode(document, nodeId)?.properties.textAndAttributes; + if (isSemanticValue(bridgeCommand.value)) { + if (entry?.kind !== ComponentPropertyKind.EXPLICIT_VALUE) { + return { + command: SetProperty({ + ...commandSpec, + property: 'textAndAttributes', + value: bridgeCommand.value + }), + bindings: {} + }; + } + return { + command: EditText({ + ...commandSpec, + operation: { + kind: ComponentTextEditKind.REPLACE_ALL, + before: entry.value, + after: bridgeCommand.value + } + }), + bindings: {} + }; + } + const expression = valueExpressionFor?.(bridgeCommand); + const expressionSource = typeof expression === 'string' ? expression : expression?.__expr__; + if (typeof expressionSource !== 'string' || !expressionSource.trim()) return false; + return { + command: SetOpaqueProperty({ + ...commandSpec, + property: 'textAndAttributes', + expression: expressionSource, + requiredBindings: componentImportBindingsFromExpression(expression?.bindings || {}) + }), + bindings: expression?.bindings || {} + }; + } + if (bridgeCommand.kind === ComponentBridgeCommandKind.SET_MASTER) { + if (bridgeCommand.value === null) { + return { + command: SetMaster({ ...commandSpec, value: null }), + bindings: {} + }; + } + if (bridgeCommand.value === undefined) return false; + const expression = valueExpressionFor?.(bridgeCommand); + const expressionSource = typeof expression === 'string' ? expression : expression?.__expr__; + if (typeof expressionSource !== 'string' || !expressionSource.trim()) return false; + return { + command: SetMaster({ + ...commandSpec, + expression: expressionSource, + requiredBindings: componentImportBindingsFromExpression(expression?.bindings || {}) + }), + bindings: expression?.bindings || {} + }; + } + if (bridgeCommand.kind === ComponentBridgeCommandKind.REMOVE_NODE) { + const node = findComponentNode(document, nodeId); + const parent = findComponentParent(document, nodeId); + if (!node || !parent || typeof bridgeCommand.parentId !== 'string') return false; + if (node.provenance.kind === ComponentNodeProvenanceKind.INHERITED) { + if (node.provenance.suppressed) return false; + return { + command: SuppressInheritedNode(commandSpec), + bindings: {}, + runtimeTargetIds: Object.freeze({ [parent.id]: bridgeCommand.parentId }) + }; + } + return { + command: RemoveNode({ ...commandSpec, runtimeIndex: bridgeCommand.index }), + bindings: {}, + runtimeTargetIds: Object.freeze({ [parent.id]: bridgeCommand.parentId }) + }; + } + if (bridgeCommand.kind === ComponentBridgeCommandKind.INTRODUCE_NODE) { + const parent = findComponentNode(document, nodeId); + const visibleChildren = parent && runtimeVisibleChildren(parent); + if (!parent) return false; + if (!Number.isInteger(bridgeCommand.index) || bridgeCommand.index < 0) return false; + const runtimeNodeName = runtimeNodeNameFor?.(bridgeCommand); + const suppressedNode = parent?.children.find(child => + child.name === runtimeNodeName && + child.provenance.kind === ComponentNodeProvenanceKind.INHERITED && + child.provenance.suppressed); + if (suppressedNode) { + return { + command: RestoreInheritedNode({ + componentId: document.componentId, + expectedRevision: document.revision, + nodeId: suppressedNode.id, + parentId: parent.id, + beforeId: suppressedNode.provenance.beforeId + }), + bindings: {}, + runtimeTargetIds: Object.freeze({ + [parent.id]: bridgeCommand.parentId, + [suppressedNode.id]: bridgeCommand.nodeId + }) + }; + } + const serialized = introducedNodeFor?.({ + document, + parentId: parent.id, + index: bridgeCommand.index, + bridgeCommand, + // A live part can be attached to a component policy whose source update + // replaces child identities. Materialize its resolved subtree so the + // reduced document describes the same inherited children that reparsing + // part(...) will resolve. + materializePartSubtree: true + }); + if (!serialized?.supported) { + return { serializationFailure: serialized }; + } + const runtimeOrderingName = runtimeOrderingNameFor?.(bridgeCommand); + const runtimeOrderingNode = typeof runtimeOrderingName === 'string' + ? parent.children.find(child => child.name === runtimeOrderingName) + : null; + const externalOrderingName = typeof runtimeOrderingName === 'string' && !runtimeOrderingNode + ? runtimeOrderingName + : null; + const beforeId = runtimeOrderingName === null + ? null + : runtimeOrderingNode?.id ?? visibleChildren[bridgeCommand.index]?.id ?? null; + const introducedNode = (document.parentComponent && parent.id === document.root.id) || + parent.provenance.kind === ComponentNodeProvenanceKind.INHERITED || + !!parent.partComponent + ? serialized.node.with({ + provenance: addedNodeProvenance(externalOrderingName + ? { beforeName: externalOrderingName } + : { beforeId }) + }) + : serialized.node; + const introducedBindings = serializedBindingsForNode(serialized, introducedNode); + return { + command: IntroduceNode({ + componentId: document.componentId, + expectedRevision: document.revision, + parentId: parent.id, + beforeId, + node: introducedNode, + runtimeIndex: bridgeCommand.index, + requiredBindings: introducedBindings.requiredBindings + }), + bindings: introducedBindings.bindings, + runtimeRename: serialized.runtimeRename, + runtimeTargetIds: Object.freeze({ + [parent.id]: bridgeCommand.parentId, + [introducedNode.id]: bridgeCommand.nodeId + }) + }; + } + if (bridgeCommand.kind === ComponentBridgeCommandKind.MOVE_NODE) { + const node = findComponentNode(document, nodeId); + const previousParent = findComponentParent(document, nodeId); + const destinationParent = findComponentNode(document, destinationParentId); + if (!node || !previousParent || !destinationParent || + !Number.isInteger(bridgeCommand.index) || bridgeCommand.index < 0) return false; + const siblings = runtimeVisibleChildren( + destinationParent, + previousParent.id === destinationParent.id ? node.id : null + ); + const runtimeOrderingName = runtimeOrderingNameFor?.(bridgeCommand); + const runtimeOrderingNode = typeof runtimeOrderingName === 'string' + ? siblings.find(child => child.name === runtimeOrderingName) + : null; + const externalOrderingName = typeof runtimeOrderingName === 'string' && !runtimeOrderingNode + ? runtimeOrderingName + : null; + const beforeId = runtimeOrderingName === null + ? null + : runtimeOrderingNode?.id ?? siblings[bridgeCommand.index]?.id ?? null; + if (node.provenance.kind === ComponentNodeProvenanceKind.INHERITED) { + if (previousParent.id === destinationParent.id || node.provenance.suppressed) return false; + const serialized = introducedNodeFor?.({ + document, + parentId: destinationParent.id, + index: bridgeCommand.index, + bridgeCommand, + partComponent: node.partComponent, + materializePartSubtree: true + }); + if (!serialized?.supported) return { serializationFailure: serialized }; + const serializedMaterialization = preserveMaterializedDescendantProvenance( + serialized.node, + node + ); + const materializedNode = (document.parentComponent && + destinationParent.id === document.root.id) || + destinationParent.provenance.kind === ComponentNodeProvenanceKind.INHERITED || + !!destinationParent.partComponent + ? serializedMaterialization.with({ + provenance: addedNodeProvenance(externalOrderingName + ? { beforeName: externalOrderingName } + : { beforeId }) + }) + : serializedMaterialization; + return { + command: MoveNode({ + ...commandSpec, + parentId: destinationParent.id, + beforeId, + runtimeFromIndex: bridgeCommand.previousIndex, + runtimeToIndex: bridgeCommand.index, + inheritanceTransition: { + kind: ComponentMoveInheritanceTransitionKind.MATERIALIZE, + node: materializedNode, + requiredBindings: serialized.requiredBindings + } + }), + bindings: serialized.bindings, + runtimeRename: serialized.runtimeRename, + runtimeTargetIds: Object.freeze({ + [previousParent.id]: bridgeCommand.previousParentId, + [destinationParent.id]: bridgeCommand.parentId, + [materializedNode.id]: bridgeCommand.nodeId + }) + }; + } + const suppressedInheritedNode = destinationParent.children.find(child => + child.provenance.kind === ComponentNodeProvenanceKind.INHERITED && + child.provenance.suppressed && + child.name === node.name + ); + if (suppressedInheritedNode && previousParent.id !== destinationParent.id) { + return { + command: MoveNode({ + ...commandSpec, + parentId: destinationParent.id, + beforeId, + runtimeFromIndex: bridgeCommand.previousIndex, + runtimeToIndex: bridgeCommand.index, + inheritanceTransition: { + kind: ComponentMoveInheritanceTransitionKind.RESTORE, + inheritedNodeId: suppressedInheritedNode.id + } + }), + bindings: {}, + runtimeTargetIds: Object.freeze({ + [previousParent.id]: bridgeCommand.previousParentId, + [destinationParent.id]: bridgeCommand.parentId, + [suppressedInheritedNode.id]: bridgeCommand.nodeId + }) + }; + } + return { + command: MoveNode({ + ...commandSpec, + parentId: destinationParent.id, + beforeId, + ...(externalOrderingName ? { orderingName: externalOrderingName } : {}), + runtimeFromIndex: bridgeCommand.previousIndex, + runtimeToIndex: bridgeCommand.index + }), + bindings: {}, + runtimeTargetIds: Object.freeze({ + [previousParent.id]: bridgeCommand.previousParentId, + [destinationParent.id]: bridgeCommand.parentId + }) + }; + } + if (bridgeCommand.kind !== ComponentBridgeCommandKind.SET_PROPERTY) return null; + if (isSemanticValue(bridgeCommand.value)) { + return { + command: SetProperty({ + ...commandSpec, + property: bridgeCommand.property, + value: bridgeCommand.value + }), + bindings: {} + }; + } + const expression = valueExpressionFor?.(bridgeCommand); + const expressionSource = typeof expression === 'string' ? expression : expression?.__expr__; + if (typeof expressionSource !== 'string' || !expressionSource.trim()) return false; + return { + command: SetOpaqueProperty({ + ...commandSpec, + property: bridgeCommand.property, + expression: expressionSource, + requiredBindings: componentImportBindingsFromExpression(expression?.bindings || {}) + }), + bindings: expression?.bindings || {} + }; +} + +export function prepareShadowScalarProjection ({ + source, + moduleId, + exportName, + componentId, + bridgeCommands, + parentDocument = null, + resolveComponentDocument = null, + beforeDocument = null, + projectionId = 'shadow-component-projection', + resolveNodeId = (_document, bridgeCommand) => bridgeCommand.nodeId, + resolveDestinationParentId = () => null, + runtimeNodeNameFor, + runtimeOrderingNameFor, + valueExpressionFor, + introducedNodeFor, + runtimeLayoutFor +}) { + if (typeof source !== 'string') { + return Object.freeze({ + supported: false, + sourceBefore: source, + sourceAfter: source, + beforeDocument: null, + document: null, + steps: Object.freeze([]), + runtimeChangeSet: null, + inverseRuntimeChangeSet: null, + requiredBindings: Object.freeze({}), + diagnostics: Object.freeze([diagnostic( + ShadowProjectionDiagnosticKind.SOURCE_UNAVAILABLE, + 'Component module source is unavailable' + )]) + }); + } + + if (beforeDocument !== null && !(beforeDocument instanceof ComponentDocument)) { + throw new Error('Shadow projection beforeDocument must be a ComponentDocument'); + } + if (beforeDocument && ( + beforeDocument.moduleId !== moduleId || + beforeDocument.exportName !== exportName || + beforeDocument.componentId !== componentId + )) { + throw new Error('Shadow projection beforeDocument does not match the requested component'); + } + const parsed = beforeDocument + ? Object.freeze({ supported: true, document: beforeDocument, diagnostics: Object.freeze([]) }) + : parseComponentSource({ + source, + moduleId, + exportName, + componentId, + parentDocument, + resolveComponentDocument + }); + if (!parsed.supported) { + return Object.freeze({ + supported: false, + sourceBefore: source, + sourceAfter: source, + beforeDocument: null, + document: null, + steps: Object.freeze([]), + runtimeChangeSet: null, + inverseRuntimeChangeSet: null, + requiredBindings: Object.freeze({}), + diagnostics: Object.freeze([diagnostic( + ShadowProjectionDiagnosticKind.SOURCE_UNSUPPORTED, + 'Component source is outside the projectional shadow subset', + { sourceDiagnostics: parsed.diagnostics } + )]) + }); + } + + const diagnostics = []; + const steps = []; + const requiredBindings = {}; + let currentDocument = parsed.document; + let currentSource = source; + for (const [commandIndex, bridgeCommand] of bridgeCommands.entries()) { + const nodeId = resolveNodeId(currentDocument, bridgeCommand); + const destinationParentId = bridgeCommand.kind === ComponentBridgeCommandKind.MOVE_NODE + ? resolveDestinationParentId(currentDocument, bridgeCommand) + : null; + if (!nodeId) { + diagnostics.push(diagnostic( + ShadowProjectionDiagnosticKind.NODE_ID_UNRESOLVED, + `Could not resolve runtime node ${bridgeCommand.nodeId} in the component document`, + { bridgeCommand } + )); + break; + } + + let translated; + try { + translated = componentCommandFor({ + document: currentDocument, + bridgeCommand, + nodeId, + destinationParentId, + runtimeNodeNameFor, + runtimeOrderingNameFor, + valueExpressionFor, + introducedNodeFor + }); + } catch (error) { + diagnostics.push(diagnostic( + ShadowProjectionDiagnosticKind.VALUE_EXPRESSION_UNAVAILABLE, + error.message, + { bridgeCommand } + )); + break; + } + if (translated === null) { + diagnostics.push(diagnostic( + ShadowProjectionDiagnosticKind.COMMAND_UNSUPPORTED, + `Component shadow projection does not support ${bridgeCommand.kind}`, + { bridgeCommand } + )); + break; + } + if (translated.serializationFailure) { + const serializationDiagnostics = translated.serializationFailure.diagnostics || []; + diagnostics.push(diagnostic( + ShadowProjectionDiagnosticKind.VALUE_EXPRESSION_UNAVAILABLE, + serializationDiagnostics[0]?.message || + `Runtime node ${bridgeCommand.nodeId} cannot be serialized projectionally`, + { bridgeCommand, serializationDiagnostics } + )); + break; + } + if (translated === false) { + diagnostics.push(diagnostic( + ShadowProjectionDiagnosticKind.VALUE_EXPRESSION_UNAVAILABLE, + bridgeCommand.kind === ComponentBridgeCommandKind.INTRODUCE_NODE + ? `Runtime node ${bridgeCommand.nodeId} cannot be serialized projectionally` + : `No source expression is available for ${nodeId}.${bridgeCommand.property}`, + { bridgeCommand } + )); + break; + } + + let reduction; + try { + reduction = reduceComponent(currentDocument, translated.command); + } catch (error) { + diagnostics.push(diagnostic( + ShadowProjectionDiagnosticKind.REDUCTION_FAILED, + error.message, + { bridgeCommand, componentCommand: translated.command } + )); + break; + } + const sourceProjection = projectComponentSource({ + source: currentSource, + beforeDocument: currentDocument, + reduction + }); + if (!sourceProjection.supported) { + const sourceDiagnostics = sourceProjection.diagnostics || []; + diagnostics.push(diagnostic( + ShadowProjectionDiagnosticKind.SOURCE_PROJECTION_FAILED, + sourceDiagnostics[0]?.message || + 'The reduced command could not be projected back into source', + { bridgeCommand, sourceDiagnostics } + )); + break; + } + const runtimeValues = bridgeCommand.kind === ComponentBridgeCommandKind.RENAME_NODE + ? { before: bridgeCommand.previousName, after: bridgeCommand.name } + : { before: bridgeCommand.previousValue, after: bridgeCommand.value }; + const runtimeProjection = projectComponentRuntime({ + beforeDocument: currentDocument, + reduction, + changeSetId: `${projectionId}:runtime:${commandIndex}`, + resolveRuntimeTargetId: semanticNodeId => + translated.runtimeTargetIds?.[semanticNodeId] || + (semanticNodeId === nodeId ? bridgeCommand.nodeId : null), + resolveRuntimeValue: ({ phase }) => Object.freeze({ + available: true, + value: runtimeValues[phase] + }), + runtimeRename: translated.runtimeRename, + resolveRuntimeLayout: spec => runtimeLayoutFor?.({ + ...spec, + bridgeCommand, + componentCommand: translated.command + }) || null + }); + if (!runtimeProjection.supported) { + diagnostics.push(diagnostic( + ShadowProjectionDiagnosticKind.RUNTIME_PROJECTION_FAILED, + 'The reduced command could not be projected into runtime operations', + { bridgeCommand, runtimeDiagnostics: runtimeProjection.diagnostics } + )); + break; + } + Object.entries(translated.bindings).forEach(([bindingModuleId, bindings]) => { + requiredBindings[bindingModuleId] = Array.from(new Set([ + ...(requiredBindings[bindingModuleId] || []), + ...bindings + ])); + }); + steps.push(Object.freeze({ + bridgeCommand, + componentCommand: translated.command, + reduction, + sourceProjection, + runtimeProjection + })); + currentDocument = sourceProjection.projectedDocument; + currentSource = sourceProjection.sourceAfter; + } + + const supported = diagnostics.length === 0 && steps.length === bridgeCommands.length; + const runtimeChangeSet = supported + ? new MorphicChangeSet({ + id: `${projectionId}:runtime`, + label: 'project component command batch', + origin: 'runtime-projection', + undoable: false, + operations: steps.flatMap(step => step.runtimeProjection.changeSet.operations), + metadata: { + reconcileChanges: false, + componentId, + fromRevision: parsed.document.revision, + toRevision: currentDocument.revision + } + }) + : null; + const inverseRuntimeChangeSet = runtimeChangeSet?.invert({ + id: `${projectionId}:runtime:inverse`, + origin: 'runtime-projection', + metadata: { rollbackOf: runtimeChangeSet.id } + }) || null; + return Object.freeze({ + supported, + sourceBefore: source, + sourceAfter: currentSource, + beforeDocument: parsed.document, + document: currentDocument, + steps: Object.freeze(steps), + runtimeChangeSet, + inverseRuntimeChangeSet, + requiredBindings: Object.freeze(Object.fromEntries( + Object.entries(requiredBindings).map(([bindingModuleId, bindings]) => + [bindingModuleId, Object.freeze(bindings)]) + )), + diagnostics: Object.freeze(diagnostics) + }); +} + +export function compareShadowProjectionToCurrentSource (shadowProjection, currentSource) { + if (!shadowProjection?.supported || !shadowProjection.document) { + throw new Error('Can only compare a supported shadow projection'); + } + const { document } = shadowProjection; + const parsedCurrent = parseComponentSource({ + source: currentSource, + moduleId: document.moduleId, + exportName: document.exportName, + componentId: document.componentId + }); + if (!parsedCurrent.supported) { + return Object.freeze({ + kind: ShadowProjectionComparisonKind.CURRENT_SOURCE_UNSUPPORTED, + matches: false, + diagnostics: parsedCurrent.diagnostics + }); + } + const matches = componentDocumentsSemanticallyEqual( + shadowProjection.document, + parsedCurrent.document + ); + return Object.freeze({ + kind: matches + ? ShadowProjectionComparisonKind.MATCH + : ShadowProjectionComparisonKind.SEMANTIC_MISMATCH, + matches, + diagnostics: Object.freeze([]) + }); +} diff --git a/lively.ide/components/reconciliation/source-adapter.js b/lively.ide/components/reconciliation/source-adapter.js new file mode 100644 index 0000000000..b43e2f38e8 --- /dev/null +++ b/lively.ide/components/reconciliation/source-adapter.js @@ -0,0 +1,883 @@ +import { parse } from 'lively.ast'; +import { + ComponentDocument, + ComponentNode, + ComponentNodeProvenanceKind, + ComponentPropertyKind, + addedNodeProvenance, + explicitProperty, + inheritedNodeProvenance, + localNodeProvenance, + opaqueProperty, + resizePolicyLayoutReference, + sourceComponentReference, + tilingLayoutModel +} from './component-document.js'; +import { + ComponentImportKind, + componentImportBinding +} from './import-bindings.js'; +import { validateComponentDocument } from './invariants.js'; + +export const ComponentSourceDiagnosticKind = Object.freeze({ + SYNTAX_ERROR: 'syntax-error', + COMPONENT_NOT_FOUND: 'component-not-found', + UNSUPPORTED_COMPONENT_CALL: 'unsupported-component-call', + UNSUPPORTED_COMPONENT_SPEC: 'unsupported-component-spec', + UNSUPPORTED_PROPERTY: 'unsupported-property', + DUPLICATE_PROPERTY: 'duplicate-property', + INVALID_NODE_NAME: 'invalid-node-name', + INVALID_ORDERING_REFERENCE: 'invalid-ordering-reference', + UNRESOLVED_PART_COMPONENT: 'unresolved-part-component', + UNSUPPORTED_SUBMORPH_STRUCTURE: 'unsupported-submorph-structure', + OPAQUE_SUBMORPH_STRUCTURE: 'opaque-submorph-structure', + DERIVED_STRUCTURE_REQUIRES_PARENT: 'derived-structure-requires-parent', + UNMODELED_LAYOUT_REFERENCE: 'unmodeled-layout-reference' +}); + +export const ComponentSourceDiagnosticSeverity = Object.freeze({ + ERROR: 'error', + WARNING: 'warning' +}); + +function rangeOf (node) { + if (!node || !Number.isInteger(node.start) || !Number.isInteger(node.end)) return null; + return Object.freeze({ start: node.start, end: node.end }); +} + +function diagnostic (kind, message, node = null, details = {}) { + return Object.freeze({ + kind, + severity: ComponentSourceDiagnosticSeverity.ERROR, + message, + range: rangeOf(node), + ...details + }); +} + +function result (document, diagnostics) { + return Object.freeze({ + supported: !!document && !diagnostics.some(({ severity }) => + severity === ComponentSourceDiagnosticSeverity.ERROR), + document, + diagnostics: Object.freeze(diagnostics) + }); +} + +function variableDeclaratorNamed (moduleAst, exportName) { + for (const statement of moduleAst.body) { + const declaration = statement.type === 'ExportNamedDeclaration' + ? statement.declaration + : statement; + if (declaration?.type !== 'VariableDeclaration') continue; + const declarator = declaration.declarations.find(({ id }) => + id.type === 'Identifier' && id.name === exportName); + if (declarator) return declarator; + } + return null; +} + +function propertyName (property) { + if (property.computed) return null; + if (property.key?.type === 'Identifier') return property.key.name; + if (property.key?.type === 'Literal' && typeof property.key.value === 'string') { + return property.key.value; + } + return null; +} + +function staticValue (node) { + if (!node) return { known: false }; + if (node.type === 'Literal' && !node.regex && + (node.value === null || ['string', 'number', 'boolean'].includes(typeof node.value))) { + return { known: true, value: node.value }; + } + if (node.type === 'UnaryExpression' && ['+', '-'].includes(node.operator)) { + const argument = staticValue(node.argument); + if (argument.known && typeof argument.value === 'number') { + return { + known: true, + value: node.operator === '-' ? -argument.value : +argument.value + }; + } + } + if (node.type === 'TemplateLiteral' && node.expressions.length === 0) { + return { known: true, value: node.quasis[0].value.cooked }; + } + if (node.type === 'ArrayExpression' && node.elements.every(Boolean)) { + const elements = node.elements.map(staticValue); + if (elements.every(({ known }) => known)) { + return { known: true, value: elements.map(({ value }) => value) }; + } + } + if (node.type === 'ObjectExpression') { + const entries = []; + for (const property of node.properties) { + if (property.type !== 'Property' || property.kind !== 'init' || property.method) { + return { known: false }; + } + const key = propertyName(property); + const value = staticValue(property.value); + if (key === null || !value.known) return { known: false }; + entries.push([key, value.value]); + } + return { known: true, value: Object.fromEntries(entries) }; + } + return { known: false }; +} + +export function layoutPropertyCannotReferenceChildren (layoutEntry) { + if (layoutEntry?.kind === ComponentPropertyKind.EXPLICIT_VALUE) { + return layoutEntry.value === null; + } + if (layoutEntry?.kind !== ComponentPropertyKind.OPAQUE_EXPRESSION) return false; + const expression = layoutEntry.expression.trim(); + if (expression === 'undefined') return true; + let node; + try { + node = parse(`(${expression})`).body[0].expression; + } catch { + return false; + } + if (node?.type !== 'NewExpression' || + node.callee?.type !== 'Identifier' || + node.callee.name !== 'ConstraintLayout') return false; + if (node.arguments.length === 0) return true; + if (node.arguments.length !== 1 || + node.arguments[0]?.type !== 'ObjectExpression') return false; + const settings = node.arguments[0].properties.filter(property => + property.type === 'Property' && + property.kind === 'init' && + !property.method && + propertyName(property) === 'submorphSettings'); + if (settings.length === 0) return true; + return settings.length === 1 && + settings[0].value?.type === 'ArrayExpression' && + settings[0].value.elements.length === 0; +} + +function importBindingsOf (moduleAst) { + const bindings = []; + for (const statement of moduleAst.body) { + if (statement.type !== 'ImportDeclaration') continue; + for (const specifier of statement.specifiers) { + const kind = specifier.type === 'ImportSpecifier' + ? ComponentImportKind.NAMED + : specifier.type === 'ImportDefaultSpecifier' + ? ComponentImportKind.DEFAULT + : ComponentImportKind.NAMESPACE; + const imported = kind === ComponentImportKind.NAMED + ? specifier.imported.name + : undefined; + bindings.push(componentImportBinding({ + kind, + moduleId: statement.source.value, + imported, + local: specifier.local.name + })); + } + } + return Object.freeze(bindings); +} + +function importInsertionIndexOf (moduleAst) { + const imports = moduleAst.body.filter(statement => statement.type === 'ImportDeclaration'); + if (imports.length) return imports[imports.length - 1].end; + return moduleAst.body[0]?.start || 0; +} + +function nodeIdFor (componentId, path) { + return path.length + ? `${componentId}:node:${path.join('.')}` + : `${componentId}:root`; +} + +function staticTilingResizePolicies (layoutNode) { + if (layoutNode?.type !== 'NewExpression' || + layoutNode.callee?.type !== 'Identifier' || + layoutNode.callee.name !== 'TilingLayout') return null; + if (layoutNode.arguments.length === 0) { + return Object.freeze({ policies: Object.freeze([]), policiesNode: null }); + } + if (layoutNode.arguments.length !== 1 || + layoutNode.arguments[0]?.type !== 'ObjectExpression') return null; + const resizePolicyProperties = layoutNode.arguments[0].properties.filter(property => + property.type === 'Property' && property.kind === 'init' && !property.method && + propertyName(property) === 'resizePolicies'); + if (resizePolicyProperties.length === 0) { + return Object.freeze({ policies: Object.freeze([]), policiesNode: null }); + } + if (resizePolicyProperties.length !== 1) return null; + const policiesNode = resizePolicyProperties[0].value; + if (policiesNode.type !== 'ArrayExpression' || policiesNode.elements.some(node => !node)) { + return null; + } + const policies = []; + for (const entry of policiesNode.elements) { + if (entry.type !== 'ArrayExpression' || entry.elements.length < 2 || + entry.elements.some(node => !node)) return null; + const target = staticValue(entry.elements[0]); + if (!target.known || typeof target.value !== 'string' || !target.value) return null; + policies.push(Object.freeze({ + targetName: target.value, + targetNode: entry.elements[0], + entryNode: entry + })); + } + return Object.freeze({ + policies: Object.freeze(policies), + policiesNode + }); +} + +function layoutExpressionTemplate (source, layoutNode, policiesNode) { + const expression = source.slice(layoutNode.start, layoutNode.end); + if (!policiesNode) return expression; + const start = policiesNode.start - layoutNode.start; + const end = policiesNode.end - layoutNode.start; + return `${expression.slice(0, start)}${expression.slice(end)}`; +} + +function resizePolicyExpressionTemplate (source, policy) { + const expression = source.slice(policy.entryNode.start, policy.entryNode.end); + const start = policy.targetNode.start - policy.entryNode.start; + const end = policy.targetNode.end - policy.entryNode.start; + return `${expression.slice(0, start)}${expression.slice(end)}`; +} + +export function parseComponentSource ({ + source, + moduleId, + exportName, + componentId = `${moduleId}#${exportName}`, + parentDocument = null, + resolveComponentDocument = null +}) { + if (typeof source !== 'string') throw new Error('Component source must be a string'); + if (typeof moduleId !== 'string' || !moduleId) throw new Error('Component source requires a moduleId'); + if (typeof exportName !== 'string' || !exportName) { + throw new Error('Component source requires an exportName'); + } + if (typeof componentId !== 'string' || !componentId) { + throw new Error('Component source requires a componentId'); + } + if (parentDocument !== null && !(parentDocument instanceof ComponentDocument)) { + throw new Error('Component source parentDocument must be a ComponentDocument'); + } + if (resolveComponentDocument !== null && typeof resolveComponentDocument !== 'function') { + throw new Error('Component source resolveComponentDocument must be a function'); + } + + const diagnostics = []; + let moduleAst; + try { + moduleAst = parse(source); + } catch (error) { + diagnostics.push(diagnostic( + ComponentSourceDiagnosticKind.SYNTAX_ERROR, + error.message, + error + )); + return result(null, diagnostics); + } + + const declarator = variableDeclaratorNamed(moduleAst, exportName); + if (!declarator) { + diagnostics.push(diagnostic( + ComponentSourceDiagnosticKind.COMPONENT_NOT_FOUND, + `Could not find component declaration ${exportName}` + )); + return result(null, diagnostics); + } + + const componentCall = declarator.init; + if (componentCall?.type !== 'CallExpression' || + componentCall.callee.type !== 'Identifier' || + componentCall.callee.name !== 'component' || + ![1, 2].includes(componentCall.arguments.length)) { + diagnostics.push(diagnostic( + ComponentSourceDiagnosticKind.UNSUPPORTED_COMPONENT_CALL, + `${exportName} must be initialized by component(spec) or component(parent, spec)`, + componentCall || declarator + )); + return result(null, diagnostics); + } + + const derived = componentCall.arguments.length === 2; + const parentNode = derived ? componentCall.arguments[0] : null; + const specNode = componentCall.arguments[derived ? 1 : 0]; + if (specNode.type !== 'ObjectExpression') { + diagnostics.push(diagnostic( + ComponentSourceDiagnosticKind.UNSUPPORTED_COMPONENT_SPEC, + 'Component specifications must be object expressions', + specNode + )); + return result(null, diagnostics); + } + + const nodeIdToAstLocation = {}; + const nodeSpecLocations = {}; + const propertyLocations = {}; + const originalExpressions = {}; + const suppressionLocations = {}; + const suppressionLocationLists = {}; + const orderingNames = {}; + const orderingLocations = {}; + const layoutModels = []; + const layoutReferenceLocations = {}; + const opaqueSubmorphExpressions = {}; + + const inheritedNode = (node, idFor = candidate => candidate.id, path = []) => + new ComponentNode({ + id: idFor(node, path), + name: node.name, + provenance: inheritedNodeProvenance({ + suppressed: node.provenance.kind === ComponentNodeProvenanceKind.INHERITED && + node.provenance.suppressed, + baseName: node.provenance.baseName || node.name + }), + partComponent: node.partComponent, + typeExpression: node.typeExpression, + properties: node.properties, + children: node.children.map((child, index) => + inheritedNode(child, idFor, [...path, index])) + }); + + const withoutTarget = node => { + if (node?.type !== 'CallExpression' || + node.callee?.type !== 'Identifier' || node.callee.name !== 'without' || + node.arguments.length !== 1) return null; + const target = staticValue(node.arguments[0]); + return target.known && typeof target.value === 'string' && target.value + ? target.value + : null; + }; + + const helperCall = (node, name) => node?.type === 'CallExpression' && + node.callee?.type === 'Identifier' && node.callee.name === name; + + const replacementTarget = node => { + if (!helperCall(node, 'replace') || node.arguments.length !== 2) return null; + const target = staticValue(node.arguments[0]); + return target.known && typeof target.value === 'string' && target.value + ? target.value + : null; + }; + + const partDocumentFor = (expression, path) => { + if (!resolveComponentDocument) return null; + const resolved = resolveComponentDocument(Object.freeze({ + expression, + moduleId, + exportName, + path: Object.freeze(path.slice()) + })); + if (resolved !== null && resolved !== undefined && !(resolved instanceof ComponentDocument)) { + throw new Error(`Resolved part ${expression} must be a ComponentDocument or null`); + } + return resolved || null; + }; + + const staticNodeName = objectNode => { + if (objectNode?.type !== 'ObjectExpression') return null; + const property = objectNode.properties.find(candidate => + candidate.type === 'Property' && propertyName(candidate) === 'name'); + const parsedName = staticValue(property?.value); + return parsedName.known && typeof parsedName.value === 'string' && parsedName.value + ? parsedName.value + : null; + }; + + let parseNode; + + const resolveOrdering = (children, additions, submorphsNode) => { + const resolved = children.slice(); + let pending = additions.slice(); + while (pending.length) { + const deferred = []; + let progress = false; + for (const child of pending) { + const beforeName = orderingNames[child.id] || null; + if (!beforeName) { + resolved.push(child); + progress = true; + continue; + } + const beforeIndex = resolved.findIndex(candidate => candidate.name === beforeName); + if (beforeIndex < 0) { + deferred.push(child); + continue; + } + const before = resolved[beforeIndex]; + resolved.splice(beforeIndex, 0, child.with({ + provenance: addedNodeProvenance({ beforeId: before.id }) + })); + progress = true; + } + if (!progress) { + for (const child of deferred) { + const sourceIndex = additions.findIndex(candidate => candidate.id === child.id); + const resolvedIds = new Set(resolved.map(candidate => candidate.id)); + const nextResolvedAddition = additions.slice(sourceIndex + 1) + .find(candidate => resolvedIds.has(candidate.id)); + const insertionIndex = nextResolvedAddition + ? resolved.findIndex(candidate => candidate.id === nextResolvedAddition.id) + : resolved.length; + resolved.splice(insertionIndex, 0, child.with({ + provenance: addedNodeProvenance({ + beforeName: orderingNames[child.id] + }) + })); + } + break; + } + pending = deferred; + } + return resolved; + }; + + const parseResolvedChildren = ( + submorphsNode, + inheritedChildren, + path, + ownerId, + allowUnknownOverrides = false + ) => { + let children = inheritedChildren.slice(); + if (!submorphsNode) return children; + if (submorphsNode.type !== 'ArrayExpression' || + submorphsNode.elements.some(node => !node)) { + diagnostics.push(diagnostic( + ComponentSourceDiagnosticKind.UNSUPPORTED_SUBMORPH_STRUCTURE, + 'Resolved submorph overrides must be a dense array expression', + submorphsNode + )); + return children; + } + + const additions = []; + for (let index = 0; index < submorphsNode.elements.length; index++) { + const element = submorphsNode.elements[index]; + const suppressedName = withoutTarget(element); + if (suppressedName) { + let targetIndex = children.findIndex(child => child.name === suppressedName); + if (targetIndex < 0) { + children.push(new ComponentNode({ + id: `${ownerId}:inherited:${encodeURIComponent(suppressedName)}`, + name: suppressedName, + provenance: inheritedNodeProvenance() + })); + targetIndex = children.length - 1; + } + const target = children[targetIndex]; + children[targetIndex] = target.with({ + provenance: inheritedNodeProvenance({ + ...target.provenance, + suppressed: true + }) + }); + const suppressionLocation = rangeOf(element); + suppressionLocations[target.id] = suppressionLocation; + (suppressionLocationLists[target.id] ||= []).push(suppressionLocation); + continue; + } + + if (helperCall(element, 'add')) { + const addition = parseNode(element, [...path, index]); + if (addition) additions.push(addition); + continue; + } + + const overrideName = staticNodeName(element); + const replacedName = replacementTarget(element); + if (helperCall(element, 'replace') && !replacedName) { + diagnostics.push(diagnostic( + ComponentSourceDiagnosticKind.UNSUPPORTED_SUBMORPH_STRUCTURE, + 'replace(name, spec) requires a non-empty static inherited name and a spec', + element + )); + continue; + } + const targetName = replacedName || overrideName; + let targetIndex = children.findIndex(child => + (child.provenance.baseName || child.name) === targetName); + if (!targetName || (targetIndex < 0 && !derived && !allowUnknownOverrides)) { + const knownNames = children.map(child => + child.provenance.baseName || child.name); + const entrySource = element?.start !== undefined && element?.end !== undefined + ? source.slice(element.start, element.end) + : null; + diagnostics.push(diagnostic( + ComponentSourceDiagnosticKind.UNSUPPORTED_SUBMORPH_STRUCTURE, + `Resolved submorph entry ${JSON.stringify(targetName)}` + + `${entrySource ? ` (${JSON.stringify(entrySource)})` : ''} must override a known name ` + + `(${knownNames.map(name => JSON.stringify(name)).join(', ') || 'none'}) or use add/without`, + element + )); + continue; + } + if (targetIndex < 0) { + children.push(new ComponentNode({ + id: `${ownerId}:inherited:${encodeURIComponent(targetName)}`, + name: targetName, + provenance: inheritedNodeProvenance({ + suppressed: !allowUnknownOverrides, + baseName: targetName + }) + })); + targetIndex = children.length - 1; + } + const target = children[targetIndex]; + const override = parseNode( + element, + [...path, index], + false, + target, + allowUnknownOverrides + ); + if (override) children[targetIndex] = override; + } + return resolveOrdering(children, additions, submorphsNode); + }; + + parseNode = ( + nodeExpression, + path, + isRoot = false, + inheritedBase = null, + allowUnknownDescendantOverrides = false + ) => { + let objectNode = nodeExpression; + let provenance = localNodeProvenance(); + let partComponent = null; + let partDocument = null; + let orderingName = null; + + if (!isRoot && helperCall(objectNode, 'replace')) { + if (!replacementTarget(objectNode)) return null; + objectNode = objectNode.arguments[1]; + } + + if (!isRoot && helperCall(objectNode, 'add')) { + if (![1, 2].includes(objectNode.arguments.length)) { + diagnostics.push(diagnostic( + ComponentSourceDiagnosticKind.UNSUPPORTED_SUBMORPH_STRUCTURE, + 'add(spec) accepts an optional static sibling name as its second argument', + objectNode + )); + return null; + } + if (objectNode.arguments.length === 2) { + const parsedOrderingName = staticValue(objectNode.arguments[1]); + if (!parsedOrderingName.known || typeof parsedOrderingName.value !== 'string' || + !parsedOrderingName.value) { + diagnostics.push(diagnostic( + ComponentSourceDiagnosticKind.INVALID_ORDERING_REFERENCE, + 'add ordering anchors must be non-empty static sibling names', + objectNode.arguments[1] + )); + return null; + } + orderingName = parsedOrderingName.value; + } + provenance = addedNodeProvenance(); + objectNode = objectNode.arguments[0]; + } + + if (!isRoot && helperCall(objectNode, 'part')) { + if (![1, 2].includes(objectNode.arguments.length) || + objectNode.arguments[0]?.type === 'SpreadElement') { + diagnostics.push(diagnostic( + ComponentSourceDiagnosticKind.UNSUPPORTED_SUBMORPH_STRUCTURE, + 'part(component) accepts one optional object override', + objectNode + )); + return null; + } + partComponent = sourceComponentReference(source.slice( + objectNode.arguments[0].start, + objectNode.arguments[0].end + )); + partDocument = partDocumentFor(partComponent.expression, path); + if (objectNode.arguments.length === 1 && !partDocument) { + diagnostics.push(diagnostic( + ComponentSourceDiagnosticKind.UNRESOLVED_PART_COMPONENT, + 'A part without a named override requires a resolved component document', + objectNode + )); + return null; + } + objectNode = objectNode.arguments[1] || null; + } + + if (objectNode !== null && objectNode?.type !== 'ObjectExpression') { + diagnostics.push(diagnostic( + ComponentSourceDiagnosticKind.UNSUPPORTED_SUBMORPH_STRUCTURE, + 'Component nodes must be object specifications, part calls, or add calls', + objectNode + )); + return null; + } + + const propertiesByName = new Map(); + for (const property of objectNode?.properties || []) { + if (property.type !== 'Property' || property.kind !== 'init' || property.method) { + diagnostics.push(diagnostic( + ComponentSourceDiagnosticKind.UNSUPPORTED_PROPERTY, + 'Spread, accessor, and method properties are not supported in component specifications', + property + )); + continue; + } + const name = propertyName(property); + if (name === null) { + diagnostics.push(diagnostic( + ComponentSourceDiagnosticKind.UNSUPPORTED_PROPERTY, + 'Computed component property names are not supported', + property + )); + continue; + } + if (propertiesByName.has(name)) { + diagnostics.push(diagnostic( + ComponentSourceDiagnosticKind.DUPLICATE_PROPERTY, + `Duplicate component property ${name}`, + property, + { property: name } + )); + continue; + } + propertiesByName.set(name, property); + } + + const nameProperty = propertiesByName.get('name'); + const parsedName = staticValue(nameProperty?.value); + const name = nameProperty + ? parsedName.value + : inheritedBase?.name || partDocument?.root.name || (isRoot ? exportName : null); + if (typeof name !== 'string' || !name) { + diagnostics.push(diagnostic( + ComponentSourceDiagnosticKind.INVALID_NODE_NAME, + 'Component node names must be non-empty static strings', + nameProperty?.value || objectNode || nodeExpression + )); + return null; + } + + const nodeId = inheritedBase?.id || nodeIdFor(componentId, path); + nodeIdToAstLocation[nodeId] = rangeOf(nodeExpression); + if (objectNode) nodeSpecLocations[nodeId] = rangeOf(objectNode); + propertyLocations[nodeId] = {}; + originalExpressions[nodeId] = {}; + if (orderingName) { + orderingNames[nodeId] = orderingName; + orderingLocations[nodeId] = rangeOf(nodeExpression.arguments[1]); + } + + const semanticProperties = { + ...(inheritedBase?.properties || partDocument?.root.properties || {}) + }; + for (const [propertyName, property] of propertiesByName) { + propertyLocations[nodeId][propertyName] = Object.freeze({ + ...rangeOf(property), + value: rangeOf(property.value) + }); + originalExpressions[nodeId][propertyName] = source.slice( + property.value.start, + property.value.end + ); + if (['name', 'type', 'submorphs'].includes(propertyName)) continue; + const parsedValue = staticValue(property.value); + semanticProperties[propertyName] = parsedValue.known + ? explicitProperty(parsedValue.value) + : opaqueProperty(originalExpressions[nodeId][propertyName]); + } + + let children = []; + const submorphsProperty = propertiesByName.get('submorphs'); + if (derived && isRoot && parentDocument) { + children = parseResolvedChildren( + submorphsProperty?.value, + parentDocument.root.children.map(child => inheritedNode(child)), + path, + nodeId + ); + } else if (inheritedBase || partDocument) { + const baseChildren = inheritedBase + ? inheritedBase.children + : partDocument.root.children.map((child, index) => inheritedNode( + child, + (_candidate, inheritedPath) => + `${nodeId}:inherited:${inheritedPath.join('.')}`, + [index] + )); + children = parseResolvedChildren( + submorphsProperty?.value, + baseChildren, + path, + nodeId, + allowUnknownDescendantOverrides + ); + } else if (partComponent && submorphsProperty) { + children = parseResolvedChildren( + submorphsProperty.value, + [], + path, + nodeId, + true + ); + } else if (submorphsProperty) { + const submorphsNode = submorphsProperty.value; + if (submorphsNode.type !== 'ArrayExpression' || submorphsNode.elements.some(node => !node)) { + opaqueSubmorphExpressions[nodeId] = rangeOf(submorphsNode); + diagnostics.push(diagnostic( + ComponentSourceDiagnosticKind.OPAQUE_SUBMORPH_STRUCTURE, + 'Dynamic submorphs are opaque; the owning node remains editable but generated descendants are not projectional targets', + submorphsNode, + { severity: ComponentSourceDiagnosticSeverity.WARNING, ownerId: nodeId } + )); + } else if (derived && isRoot && submorphsNode.elements.length) { + diagnostics.push(diagnostic( + ComponentSourceDiagnosticKind.DERIVED_STRUCTURE_REQUIRES_PARENT, + 'Derived submorph overrides require a resolved parent component document', + submorphsNode + )); + } else { + children = submorphsNode.elements + .map((child, index) => parseNode(child, [...path, index])) + .filter(Boolean); + const additions = children.filter(child => + child.provenance.kind === ComponentNodeProvenanceKind.ADDED); + const ordinaryChildren = children.filter(child => + child.provenance.kind !== ComponentNodeProvenanceKind.ADDED); + children = resolveOrdering(ordinaryChildren, additions, submorphsNode); + } + } + + const typeProperty = propertiesByName.get('type'); + const layoutProperty = propertiesByName.get('layout'); + if (layoutProperty) { + const parsedLayout = staticTilingResizePolicies(layoutProperty.value); + if (parsedLayout) { + const { policies, policiesNode } = parsedLayout; + const targets = []; + const references = []; + let modeled = true; + for (const policy of policies) { + const target = children.find(child => child.name === policy.targetName); + if (!target || targets.includes(target.id)) { + modeled = false; + diagnostics.push(diagnostic( + ComponentSourceDiagnosticKind.UNMODELED_LAYOUT_REFERENCE, + !target + ? `Could not resolve layout resize policy target ${policy.targetName}` + : `Duplicate layout resize policy target ${policy.targetName}`, + policy.targetNode, + { + severity: ComponentSourceDiagnosticSeverity.WARNING, + ownerId: nodeId, + targetName: policy.targetName + } + )); + continue; + } + targets.push(target.id); + references.push(resizePolicyLayoutReference({ + targetId: target.id, + expressionTemplate: resizePolicyExpressionTemplate(source, policy) + })); + } + if (modeled) { + layoutModels.push(tilingLayoutModel({ + ownerId: nodeId, + expressionTemplate: layoutExpressionTemplate( + source, + layoutProperty.value, + policiesNode + ), + references + })); + layoutReferenceLocations[nodeId] = Object.freeze(Object.fromEntries( + policies.map((policy, index) => [references[index].targetId, Object.freeze({ + kind: references[index].kind, + target: rangeOf(policy.targetNode), + entry: rangeOf(policy.entryNode) + })]) + )); + } + } else if (layoutProperty.value?.type === 'NewExpression' && + layoutProperty.value.callee?.type === 'Identifier' && + layoutProperty.value.callee.name === 'TilingLayout') { + diagnostics.push(diagnostic( + ComponentSourceDiagnosticKind.UNMODELED_LAYOUT_REFERENCE, + 'TilingLayout resize policies must use a static array to support projection', + layoutProperty.value, + { severity: ComponentSourceDiagnosticSeverity.WARNING, ownerId: nodeId } + )); + } + } + return new ComponentNode({ + id: nodeId, + name, + provenance: inheritedBase + ? inheritedNodeProvenance({ + ...inheritedBase.provenance, + hasLocalOverrides: true + }) + : provenance, + partComponent: partComponent || inheritedBase?.partComponent || null, + typeExpression: typeProperty + ? source.slice(typeProperty.value.start, typeProperty.value.end) + : inheritedBase?.typeExpression || partDocument?.root.typeExpression || null, + properties: semanticProperties, + children + }); + }; + + const root = parseNode(specNode, [], true); + if (!root || diagnostics.some(({ severity }) => + severity === ComponentSourceDiagnosticSeverity.ERROR)) { + return result(null, diagnostics); + } + + const document = new ComponentDocument({ + componentId, + moduleId, + exportName, + parentComponent: parentNode + ? sourceComponentReference(source.slice(parentNode.start, parentNode.end)) + : null, + root, + layoutModels, + sourceMetadata: { + componentRange: rangeOf(componentCall), + declarationRange: rangeOf(declarator), + specRange: rangeOf(specNode), + nodeIdToAstLocation, + nodeSpecLocations, + propertyLocations, + originalExpressions, + suppressionLocations, + suppressionLocationLists, + orderingLocations, + layoutReferenceLocations, + opaqueSubmorphExpressions, + parentDocument, + resolveComponentDocument, + importBindings: importBindingsOf(moduleAst), + importDeclarationCount: moduleAst.body.filter(statement => + statement.type === 'ImportDeclaration').length, + importInsertionIndex: importInsertionIndexOf(moduleAst) + } + }); + const invariantDiagnostics = validateComponentDocument(document); + if (invariantDiagnostics.length) { + diagnostics.push(...invariantDiagnostics.map(invariant => Object.freeze({ + ...invariant, + severity: ComponentSourceDiagnosticSeverity.ERROR, + range: nodeIdToAstLocation[invariant.nodeId] || null + }))); + return result(null, diagnostics); + } + return result(document, diagnostics); +} diff --git a/lively.ide/components/reconciliation/source-projector.js b/lively.ide/components/reconciliation/source-projector.js new file mode 100644 index 0000000000..d579e68f13 --- /dev/null +++ b/lively.ide/components/reconciliation/source-projector.js @@ -0,0 +1,1587 @@ +import { + ComponentDocument, + ComponentNode, + ComponentNodeProvenanceKind, + ComponentPropertyKind, + findComponentLayoutModel, + findComponentNode, + findComponentParent +} from './component-document.js'; +import { ComponentSemanticDeltaKind } from './reducer.js'; +import { ComponentMoveInheritanceTransitionKind } from './commands.js'; +import { + layoutPropertyCannotReferenceChildren, + parseComponentSource +} from './source-adapter.js'; +import { + ComponentImportKind, + componentImportBinding +} from './import-bindings.js'; +import { parse } from 'lively.ast'; + +export const ComponentSourceProjectionDiagnosticKind = Object.freeze({ + UNSUPPORTED_DELTA: 'unsupported-delta', + MISSING_SOURCE_METADATA: 'missing-source-metadata', + UNSUPPORTED_EXPLICIT_VALUE: 'unsupported-explicit-value', + IMPORT_BINDING_CONFLICT: 'import-binding-conflict', + PROJECTED_SOURCE_INVALID: 'projected-source-invalid', + PROJECTED_SEMANTICS_MISMATCH: 'projected-semantics-mismatch' +}); + +function diagnostic (kind, message, details = {}) { + return Object.freeze({ kind, message, ...details }); +} + +function explicitValueSource (value) { + if (value === null || typeof value === 'string' || typeof value === 'boolean') { + return JSON.stringify(value); + } + if (typeof value === 'number' && Number.isFinite(value)) { + return Object.is(value, -0) ? '-0' : String(value); + } + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index++) { + if (!(index in value)) return null; + } + const values = value.map(explicitValueSource); + if (values.every(source => source !== null)) return `[${values.join(', ')}]`; + return null; + } + if (value && Object.getPrototypeOf(value) === Object.prototype) { + const entries = Object.entries(value).map(([key, nested]) => { + const nestedSource = explicitValueSource(nested); + return nestedSource === null ? null : `${JSON.stringify(key)}: ${nestedSource}`; + }); + if (entries.every(Boolean)) return `{ ${entries.join(', ')} }`; + } + return null; +} + +function propertyEntrySource (entry) { + if (entry?.kind === ComponentPropertyKind.OPAQUE_EXPRESSION) return entry.expression; + if (entry?.kind === ComponentPropertyKind.EXPLICIT_VALUE) { + return explicitValueSource(entry.value); + } + return null; +} + +function propertyKeySource (property) { + return /^[A-Za-z_$][\w$]*$/.test(property) ? property : JSON.stringify(property); +} + +function componentNodeSource (node, document, insidePartOverride = false) { + const entries = [`name: ${JSON.stringify(node.name)}`]; + if (node.typeExpression) entries.push(`type: ${node.typeExpression}`); + for (const [property, entry] of Object.entries(node.properties)) { + const valueSource = propertyEntrySource(entry); + if (typeof valueSource !== 'string') return null; + entries.push(`${propertyKeySource(property)}: ${valueSource}`); + } + if (node.children.length) { + const childSources = node.children.map(child => { + const childSource = componentNodeSource(child, document, !!node.partComponent); + if (childSource === null) return null; + return child.provenance.kind === ComponentNodeProvenanceKind.INHERITED && + child.provenance.suppressed + ? `${childSource}, without(${JSON.stringify(child.name)})` + : childSource; + }); + if (childSources.some(source => source === null)) return null; + entries.push(`submorphs: [${childSources.join(', ')}]`); + } + const specification = `{ ${entries.join(', ')} }`; + const nodeSource = node.partComponent && + (!insidePartOverride || + node.provenance.kind === ComponentNodeProvenanceKind.ADDED) + ? `part(${node.partComponent.expression}, ${specification})` + : specification; + if (node.provenance.kind !== ComponentNodeProvenanceKind.ADDED) return nodeSource; + const before = node.provenance.beforeId && findComponentNode(document, node.provenance.beforeId); + const beforeName = node.provenance.beforeName || before?.name || null; + return `add(${nodeSource}${beforeName ? `, ${JSON.stringify(beforeName)}` : ''})`; +} + +function indentationAt (source, index) { + const lineStart = source.lastIndexOf('\n', index - 1) + 1; + return source.slice(lineStart, index).match(/^[ \t]*/)[0]; +} + +function orderedPropertyLocations (document, nodeId) { + return Object.entries(document.sourceMetadata.propertyLocations?.[nodeId] || {}) + .map(([property, location]) => ({ property, ...location })) + .sort((left, right) => left.start - right.start); +} + +function insertPropertyChange (source, document, nodeId, property, valueSource) { + const nodeLocation = document.sourceMetadata.nodeSpecLocations?.[nodeId] || + document.sourceMetadata.nodeIdToAstLocation?.[nodeId]; + if (!nodeLocation) return null; + const locations = orderedPropertyLocations(document, nodeId); + const parentIndent = indentationAt(source, nodeLocation.start); + const firstPropertyLineStart = locations.length + ? source.lastIndexOf('\n', locations[0].start - 1) + 1 + : null; + const firstPropertyStartsLine = locations.length && + !source.slice(firstPropertyLineStart, locations[0].start).trim(); + const propertyIndent = locations.length + ? firstPropertyStartsLine + ? indentationAt(source, locations[0].start) + : `${parentIndent} ` + : `${parentIndent} `; + const propertySource = `${propertyKeySource(property)}: ${valueSource}`; + const submorphs = locations.find(location => location.property === 'submorphs'); + if (submorphs) { + return Object.freeze({ + action: 'insert', + start: submorphs.start, + end: submorphs.start, + text: `${propertySource},\n${propertyIndent}` + }); + } + const lastProperty = locations[locations.length - 1]; + if (lastProperty) { + return Object.freeze({ + action: 'insert', + start: lastProperty.end, + end: lastProperty.end, + text: `,\n${propertyIndent}${propertySource}` + }); + } + return Object.freeze({ + action: 'insert', + start: nodeLocation.start + 1, + end: nodeLocation.start + 1, + text: `\n${propertyIndent}${propertySource}\n${parentIndent}` + }); +} + +function removePropertyChange (source, document, nodeId, property) { + const locations = orderedPropertyLocations(document, nodeId); + const index = locations.findIndex(location => location.property === property); + if (index < 0) return null; + const location = locations[index]; + const previous = locations[index - 1]; + const next = locations[index + 1]; + if (next) { + return Object.freeze({ + action: 'remove', + start: location.start, + end: next.start, + text: '' + }); + } + if (previous) { + return Object.freeze({ + action: 'remove', + start: previous.end, + end: location.end, + text: '' + }); + } + let end = location.end; + while (source[end] === ' ' || source[end] === '\t') end++; + if (source[end] === ',') end++; + return Object.freeze({ + action: 'remove', + start: location.start, + end, + text: '' + }); +} + +function removeNodeChange (source, document, nodeId, parentId) { + const parent = findComponentNode(document, parentId); + const location = document.sourceMetadata.nodeIdToAstLocation?.[nodeId]; + if (!parent || !location) return null; + let end = location.end; + while (source[end] === ' ' || source[end] === '\t' || source[end] === '\n') end++; + if (source[end] === ',') { + end++; + while (source[end] === ' ' || source[end] === '\t') end++; + return Object.freeze({ + action: 'remove', + start: location.start, + end, + text: '' + }); + } + let start = location.start; + while (start > 0 && ( + source[start - 1] === ' ' || + source[start - 1] === '\t' || + source[start - 1] === '\n' + )) start--; + if (source[start - 1] === ',') start--; + return Object.freeze({ + action: 'remove', + start, + end: location.end, + text: '' + }); +} + +function introduceNodeChange (source, document, parentId, nodeSource) { + const parent = findComponentNode(document, parentId); + if (!parent) return null; + if (document.sourceMetadata.opaqueSubmorphExpressions?.[parentId]) return null; + const submorphsLocation = document.sourceMetadata + .propertyLocations?.[parentId]?.submorphs?.value; + if (!submorphsLocation) { + return insertPropertyChange(source, document, parentId, 'submorphs', `[${nodeSource}]`); + } + return Object.freeze({ + action: 'insert', + start: submorphsLocation.end - 1, + end: submorphsLocation.end - 1, + text: `${parent.children.length ? ', ' : ''}${nodeSource}` + }); +} + +function insertMovedNodeChange (source, document, parentId, index, nodeSource) { + const parent = findComponentNode(document, parentId); + if (!parent || !Number.isInteger(index) || index < 0 || index > parent.children.length) { + return null; + } + for (let previousIndex = index - 1; previousIndex >= 0; previousIndex--) { + const previousLocation = document.sourceMetadata + .nodeIdToAstLocation?.[parent.children[previousIndex].id]; + if (!previousLocation) continue; + return Object.freeze({ + action: 'insert', + start: previousLocation.end, + end: previousLocation.end, + text: `, ${nodeSource}` + }); + } + for (let nextIndex = index; nextIndex < parent.children.length; nextIndex++) { + const nextLocation = document.sourceMetadata + .nodeIdToAstLocation?.[parent.children[nextIndex].id]; + if (!nextLocation) continue; + return Object.freeze({ + action: 'insert', + start: nextLocation.start, + end: nextLocation.start, + text: `${nodeSource}, ` + }); + } + return introduceNodeChange(source, document, parentId, nodeSource); +} + +function movedNodeSource (source, beforeDocument, afterDocument, nodeId) { + const nodeLocation = beforeDocument.sourceMetadata.nodeIdToAstLocation?.[nodeId]; + const beforeNode = findComponentNode(beforeDocument, nodeId); + const node = findComponentNode(afterDocument, nodeId); + if (!nodeLocation || !beforeNode || !node) return null; + let nodeSource = source.slice(nodeLocation.start, nodeLocation.end); + const wasAdded = beforeNode.provenance.kind === ComponentNodeProvenanceKind.ADDED; + const isAdded = node.provenance.kind === ComponentNodeProvenanceKind.ADDED; + if (wasAdded && !isAdded) { + try { + const expression = parse(nodeSource).body?.[0]?.expression; + if (!helperCallExpression(expression, 'add') || !expression.arguments[0]) return null; + return nodeSource.slice(expression.arguments[0].start, expression.arguments[0].end); + } catch (error) { + return null; + } + } + if (!wasAdded && isAdded) { + const before = node.provenance.beforeId && findComponentNode(afterDocument, node.provenance.beforeId); + const beforeName = node.provenance.beforeName || before?.name || null; + return `add(${nodeSource}${beforeName ? `, ${JSON.stringify(beforeName)}` : ''})`; + } + if (!isAdded) return nodeSource; + + const orderingLocation = beforeDocument.sourceMetadata.orderingLocations?.[nodeId]; + const before = node.provenance.beforeId && findComponentNode(afterDocument, node.provenance.beforeId); + const beforeName = node.provenance.beforeName || before?.name || null; + const orderingSource = beforeName ? JSON.stringify(beforeName) : null; + if (orderingLocation) { + const orderingStart = orderingLocation.start - nodeLocation.start; + const orderingEnd = orderingLocation.end - nodeLocation.start; + if (orderingStart < 0 || orderingEnd > nodeSource.length) return null; + if (orderingSource) { + return nodeSource.slice(0, orderingStart) + orderingSource + nodeSource.slice(orderingEnd); + } + const delimiter = nodeSource.lastIndexOf(',', orderingStart); + if (delimiter < 0) return null; + return nodeSource.slice(0, delimiter) + nodeSource.slice(orderingEnd); + } + if (!orderingSource) return nodeSource; + const callEnd = nodeSource.lastIndexOf(')'); + if (callEnd < 0) return null; + return `${nodeSource.slice(0, callEnd)}, ${orderingSource}${nodeSource.slice(callEnd)}`; +} + +function orderingRewriteChange (source, beforeDocument, afterDocument, nodeId) { + const location = beforeDocument.sourceMetadata.nodeIdToAstLocation?.[nodeId]; + const text = movedNodeSource(source, beforeDocument, afterDocument, nodeId); + if (!location || typeof text !== 'string') return null; + return Object.freeze({ + action: 'replace', + start: location.start, + end: location.end, + text + }); +} + +function helperCallExpression (node, name) { + return node?.type === 'CallExpression' && + node.callee?.type === 'Identifier' && node.callee.name === name; +} + +function reparentNodeChanges (source, beforeDocument, afterDocument, semanticDelta) { + const nodeLocation = beforeDocument.sourceMetadata + .nodeIdToAstLocation?.[semanticDelta.nodeId]; + if (!nodeLocation) return null; + const nodeSource = movedNodeSource( + source, + beforeDocument, + afterDocument, + semanticDelta.nodeId + ); + if (nodeSource === null) return null; + const removal = removeNodeChange( + source, + beforeDocument, + semanticDelta.nodeId, + semanticDelta.fromParentId + ); + const insertion = insertMovedNodeChange( + source, + beforeDocument, + semanticDelta.toParentId, + semanticDelta.toIndex, + nodeSource + ); + const previousParent = findComponentNode(beforeDocument, semanticDelta.fromParentId); + const orderingChanges = previousParent?.children + .filter(child => + child.provenance.kind === ComponentNodeProvenanceKind.ADDED && + child.provenance.beforeId === semanticDelta.nodeId) + .map(child => orderingRewriteChange( + source, + beforeDocument, + afterDocument, + child.id + )) || []; + return removal && insertion && orderingChanges.every(Boolean) + ? [removal, insertion, ...orderingChanges] + : null; +} + +function suppressInheritedNodeChange (source, document, semanticDelta) { + const node = findComponentNode(document, semanticDelta.nodeId); + const parentId = semanticDelta.parentId; + if (document.sourceMetadata.opaqueSubmorphExpressions?.[parentId]) return null; + const arrayLocation = document.sourceMetadata + .propertyLocations?.[parentId]?.submorphs?.value; + const callSource = `without(${JSON.stringify(node?.name)})`; + if (!node) return null; + if (!arrayLocation) { + return insertPropertyChange(source, document, parentId, 'submorphs', `[${callSource}]`); + } + const contents = source.slice(arrayLocation.start + 1, arrayLocation.end - 1); + return Object.freeze({ + action: 'insert', + start: arrayLocation.end - 1, + end: arrayLocation.end - 1, + text: `${contents.trim() ? ', ' : ''}${callSource}` + }); +} + +function suppressionRemovalChange (source, location) { + let end = location.end; + while (/\s/.test(source[end] || '')) end++; + if (source[end] === ',') { + end++; + return Object.freeze({ action: 'remove', start: location.start, end, text: '' }); + } + let start = location.start; + while (start > 0 && /\s/.test(source[start - 1])) start--; + if (source[start - 1] === ',') start--; + return Object.freeze({ action: 'remove', start, end: location.end, text: '' }); +} + +function restoreInheritedNodeChanges (source, document, semanticDelta) { + const locations = document.sourceMetadata + .suppressionLocationLists?.[semanticDelta.nodeId] || + [document.sourceMetadata.suppressionLocations?.[semanticDelta.nodeId]].filter(Boolean); + return locations.length + ? locations.map(location => suppressionRemovalChange(source, location)) + : null; +} + +function consolidateRestoredNodeChange ( + source, + beforeDocument, + afterDocument, + semanticDelta +) { + if (!semanticDelta.consolidated) return Object.freeze([]); + const restoredNode = findComponentNode( + afterDocument, + semanticDelta.consolidatedNodeId + ); + const nodeSource = restoredNode && + componentNodeSource(restoredNode, afterDocument, true); + if (typeof nodeSource !== 'string') return null; + const location = beforeDocument.sourceMetadata + .nodeIdToAstLocation?.[semanticDelta.consolidatedNodeId]; + if (location) { + return Object.freeze([Object.freeze({ + action: 'replace', + start: location.start, + end: location.end, + text: nodeSource + })]); + } + const parent = findComponentParent( + beforeDocument, + semanticDelta.consolidatedNodeId + ); + const introduction = parent && + introduceNodeChange(source, beforeDocument, parent.id, nodeSource); + return introduction ? Object.freeze([introduction]) : null; +} + +function reorderNodeChange (source, beforeDocument, afterDocument, parentId, movedNodeId) { + const parentBefore = findComponentNode(beforeDocument, parentId); + const parentAfter = findComponentNode(afterDocument, parentId); + const arrayLocation = beforeDocument.sourceMetadata + .propertyLocations?.[parentId]?.submorphs?.value; + if (!parentBefore || !parentAfter || !arrayLocation || + parentBefore.children.length !== parentAfter.children.length) return null; + const locations = parentBefore.children.map(child => + beforeDocument.sourceMetadata.nodeIdToAstLocation?.[child.id]); + if (locations.some(location => !location)) return null; + let text = source.slice(arrayLocation.start, locations[0].start); + for (let index = 0; index < parentAfter.children.length; index++) { + const child = parentAfter.children[index]; + const location = beforeDocument.sourceMetadata.nodeIdToAstLocation?.[child.id]; + if (!location) return null; + const childSource = child.id === movedNodeId + ? movedNodeSource(source, beforeDocument, afterDocument, child.id) + : source.slice(location.start, location.end); + if (childSource === null) return null; + text += childSource; + text += source.slice( + locations[index].end, + locations[index + 1]?.start ?? arrayLocation.end + ); + } + return Object.freeze({ + action: 'replace', + start: arrayLocation.start, + end: arrayLocation.end, + text + }); +} + +function renameLayoutReferenceChanges (document, nodeId, name) { + const owner = findComponentParent(document, nodeId); + const layoutLocation = owner && document.sourceMetadata + .propertyLocations?.[owner.id]?.layout; + if (!layoutLocation) return Object.freeze([]); + const model = findComponentLayoutModel(document, owner.id); + if (!model) { + const layoutEntry = owner.properties.layout; + return layoutPropertyCannotReferenceChildren(layoutEntry) + ? Object.freeze([]) + : null; + } + const reference = model.references.find(candidate => candidate.targetId === nodeId); + if (!reference) return Object.freeze([]); + const location = document.sourceMetadata + .layoutReferenceLocations?.[owner.id]?.[nodeId]?.target; + if (!location) return null; + return Object.freeze([Object.freeze({ + action: 'replace', + start: location.start, + end: location.end, + text: JSON.stringify(name) + })]); +} + +function renameOrderingReferenceChanges ( + source, + beforeDocument, + afterDocument, + nodeId +) { + const owner = findComponentParent(beforeDocument, nodeId); + if (!owner) return Object.freeze([]); + const targetLocation = beforeDocument.sourceMetadata + .nodeIdToAstLocation?.[nodeId]; + if (!targetLocation) return null; + const dependants = owner.children.filter(child => + child.provenance.kind === ComponentNodeProvenanceKind.ADDED && + child.provenance.beforeId === nodeId); + const changes = []; + const movedSources = []; + for (const dependant of dependants) { + const location = beforeDocument.sourceMetadata + .nodeIdToAstLocation?.[dependant.id]; + const rewrittenSource = movedNodeSource( + source, + beforeDocument, + afterDocument, + dependant.id + ); + if (!location || rewrittenSource === null) return null; + if (location.start < targetLocation.start) { + const removal = removeNodeChange( + source, + beforeDocument, + dependant.id, + owner.id + ); + if (!removal) return null; + changes.push(removal); + movedSources.push(rewrittenSource); + } else { + changes.push(Object.freeze({ + action: 'replace', + start: location.start, + end: location.end, + text: rewrittenSource + })); + } + } + if (movedSources.length) { + changes.push(Object.freeze({ + action: 'insert', + start: targetLocation.end, + end: targetLocation.end, + text: `, ${movedSources.join(', ')}` + })); + } + return Object.freeze(changes); +} + +function removeLayoutReferenceChanges (document, nodeId) { + const owner = findComponentParent(document, nodeId); + const layoutLocation = owner && document.sourceMetadata + .propertyLocations?.[owner.id]?.layout; + if (!layoutLocation) return Object.freeze([]); + const model = findComponentLayoutModel(document, owner.id); + if (!model) { + const layoutEntry = owner.properties.layout; + return layoutPropertyCannotReferenceChildren(layoutEntry) + ? Object.freeze([]) + : null; + } + const referenceIndex = model.references.findIndex(reference => + reference.targetId === nodeId); + if (referenceIndex < 0) return Object.freeze([]); + const locations = model.references.map(reference => document.sourceMetadata + .layoutReferenceLocations?.[owner.id]?.[reference.targetId]?.entry); + if (locations.some(location => !location)) return null; + const location = locations[referenceIndex]; + const previous = locations[referenceIndex - 1]; + const next = locations[referenceIndex + 1]; + return Object.freeze([Object.freeze({ + action: 'remove', + start: next ? location.start : previous ? previous.end : location.start, + end: next ? next.start : location.end, + text: '' + })]); +} + +function applyChange (source, change) { + return source.slice(0, change.start) + change.text + source.slice(change.end); +} + +function applyChanges (source, changes) { + return changes + .slice() + .sort((left, right) => right.start - left.start) + .reduce((updated, change) => applyChange(updated, change), source); +} + +function inheritedRenameChange (source, document, node, nameChange) { + const baseName = node.provenance.baseName || node.name; + if (node.name !== baseName) return nameChange; + const nodeLocation = document.sourceMetadata.nodeIdToAstLocation?.[node.id]; + if (!nodeLocation || nameChange.start < nodeLocation.start || + nameChange.end > nodeLocation.end) return null; + const nodeSource = source.slice(nodeLocation.start, nodeLocation.end); + if (/^replace\s*\(/.test(nodeSource)) return nameChange; + const renamedNodeSource = applyChange(nodeSource, { + ...nameChange, + start: nameChange.start - nodeLocation.start, + end: nameChange.end - nodeLocation.start + }); + return Object.freeze({ + action: 'replace', + start: nodeLocation.start, + end: nodeLocation.end, + text: `replace(${JSON.stringify(baseName)}, ${renamedNodeSource})` + }); +} + +function canonicalImportModuleId (moduleId) { + const [packageName] = moduleId.split('/'); + if (packageName.startsWith('lively.')) return packageName; + return moduleId.replace(/\/index\.js$/, ''); +} + +function sameImportBinding (left, right) { + return left.kind === right.kind && + canonicalImportModuleId(left.moduleId) === canonicalImportModuleId(right.moduleId) && + left.imported === right.imported && left.local === right.local; +} + +function importStatementSource (binding) { + const moduleSource = JSON.stringify(binding.moduleId); + if (binding.kind === ComponentImportKind.DEFAULT) { + return `import ${binding.local} from ${moduleSource};`; + } + if (binding.kind === ComponentImportKind.NAMESPACE) { + return `import * as ${binding.local} from ${moduleSource};`; + } + const imported = binding.imported === binding.local + ? binding.imported + : `${binding.imported} as ${binding.local}`; + return `import { ${imported} } from ${moduleSource};`; +} + +function importChangesFor (source, document, requiredBindings, diagnostics) { + if (!requiredBindings?.length) return []; + const existingBindings = document.sourceMetadata.importBindings || []; + const missing = []; + for (const required of requiredBindings) { + if ([...existingBindings, ...missing].some(existing => + sameImportBinding(existing, required))) continue; + const conflict = [...existingBindings, ...missing].find( + existing => existing.local === required.local + ); + if (conflict) { + diagnostics.push(diagnostic( + ComponentSourceProjectionDiagnosticKind.IMPORT_BINDING_CONFLICT, + `Local binding ${required.local} is already imported from ${conflict.moduleId}`, + { requiredBinding: required, existingBinding: conflict } + )); + continue; + } + missing.push(required); + } + if (diagnostics.length || !missing.length) return []; + const insertionIndex = document.sourceMetadata.importInsertionIndex; + if (!Number.isInteger(insertionIndex) || insertionIndex < 0 || insertionIndex > source.length) { + diagnostics.push(diagnostic( + ComponentSourceProjectionDiagnosticKind.MISSING_SOURCE_METADATA, + 'The component document has no valid import insertion location' + )); + return []; + } + const statements = missing.map(importStatementSource).join('\n'); + const text = document.sourceMetadata.importDeclarationCount > 0 + ? `\n${statements}` + : `${statements}\n\n`; + return [Object.freeze({ + action: 'insert', + start: insertionIndex, + end: insertionIndex, + text + })]; +} + +function canonicalSemanticValue (value) { + if (Array.isArray(value)) return value.map(canonicalSemanticValue); + if (value && Object.getPrototypeOf(value) === Object.prototype) { + if (value.kind === ComponentPropertyKind.OPAQUE_EXPRESSION) { + const expression = canonicalOpaqueExpression(value.expression); + const literal = canonicalLiteralExpressionValue(expression); + if (literal.supported) { + return { + kind: ComponentPropertyKind.EXPLICIT_VALUE, + value: canonicalSemanticValue(literal.value) + }; + } + return { + kind: value.kind, + expression + }; + } + return Object.fromEntries(Object.keys(value).sort().map(key => + [key, canonicalSemanticValue(value[key])] + )); + } + return value; +} + +function canonicalLiteralExpressionValue (expression) { + if (!expression || typeof expression !== 'object') { + return { supported: false }; + } + if (expression.type === 'Literal') { + return { supported: true, value: expression.value }; + } + if (expression.type === 'UnaryExpression' && + ['+', '-'].includes(expression.operator)) { + const argument = canonicalLiteralExpressionValue(expression.argument); + if (!argument.supported || typeof argument.value !== 'number') { + return { supported: false }; + } + return { + supported: true, + value: expression.operator === '-' ? -argument.value : argument.value + }; + } + if (expression.type === 'ArrayExpression') { + const elements = expression.elements.map(canonicalLiteralExpressionValue); + return elements.every(element => element.supported) + ? { supported: true, value: elements.map(element => element.value) } + : { supported: false }; + } + if (expression.type !== 'ObjectExpression') return { supported: false }; + const entries = []; + for (const property of expression.properties) { + if (property.type !== 'Property' || property.computed || + property.kind !== 'init') return { supported: false }; + const key = property.key.type === 'Identifier' + ? property.key.name + : property.key.type === 'Literal' ? property.key.value : null; + const nested = canonicalLiteralExpressionValue(property.value); + if (key === null || !nested.supported) return { supported: false }; + entries.push([key, nested.value]); + } + return { supported: true, value: Object.fromEntries(entries) }; +} + +function canonicalOpaqueExpression (expression) { + try { + const expressionNode = parse(`(${expression})`).body[0].expression; + const canonicalNode = value => { + if (Array.isArray(value)) return value.map(canonicalNode); + if (!value || typeof value !== 'object') return value; + return Object.fromEntries(Object.keys(value) + .filter(key => + !['start', 'end', 'loc'].includes(key) && + !(key === 'raw' && value.type === 'Literal')) + .sort() + .map(key => [key, canonicalNode(value[key])])); + }; + return canonicalNode(expressionNode); + } catch { + return expression.trim(); + } +} + +function semanticNodeSnapshot (node, modeledLayoutOwners) { + return { + id: node.id, + name: node.name, + provenance: canonicalSemanticValue(node.provenance), + partComponent: canonicalSemanticValue(node.partComponent), + typeExpression: node.typeExpression, + properties: Object.fromEntries(Object.keys(node.properties).sort() + .filter(property => property !== 'layout' || !modeledLayoutOwners.has(node.id)) + .map(property => [property, canonicalSemanticValue(node.properties[property])])), + children: node.children.map(child => semanticNodeSnapshot(child, modeledLayoutOwners)) + }; +} + +function semanticSnapshotValue (document) { + const modeledLayoutOwners = new Set(document.layoutModels.map(model => model.ownerId)); + return { + componentId: document.componentId, + moduleId: document.moduleId, + exportName: document.exportName, + parentComponent: canonicalSemanticValue(document.parentComponent), + layoutModels: canonicalSemanticValue(document.layoutModels), + root: semanticNodeSnapshot(document.root, modeledLayoutOwners) + }; +} + +function semanticSnapshot (document) { + return JSON.stringify(semanticSnapshotValue(document)); +} + +function documentWithProjectedPropertyEntry ( + document, + projectedDocument, + nodeId, + property +) { + const projectedNode = findComponentNode(projectedDocument, nodeId); + if (!projectedNode) return document; + const projectedEntry = projectedNode.properties[property]; + const replaceEntry = node => { + if (node.id === nodeId) { + const properties = { ...node.properties }; + if (projectedEntry === undefined) delete properties[property]; + else properties[property] = projectedEntry; + return node.with({ properties }); + } + return node.with({ children: node.children.map(replaceEntry) }); + }; + return new ComponentDocument({ + revision: document.revision, + componentId: document.componentId, + moduleId: document.moduleId, + exportName: document.exportName, + parentComponent: document.parentComponent, + root: replaceEntry(document.root), + layoutModels: document.layoutModels, + sourceMetadata: document.sourceMetadata + }); +} + +function firstSemanticDifference (left, right, path = []) { + if (Object.is(left, right)) return null; + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { + return { path, left, right }; + } + for (let index = 0; index < left.length; index++) { + const difference = firstSemanticDifference(left[index], right[index], [...path, index]); + if (difference) return difference; + } + return null; + } + if (left && right && typeof left === 'object' && typeof right === 'object') { + const keys = Array.from(new Set([...Object.keys(left), ...Object.keys(right)])).sort(); + for (const key of keys) { + const difference = firstSemanticDifference(left[key], right[key], [...path, key]); + if (difference) return difference; + } + return null; + } + return { path, left, right }; +} + +function semanticDifferenceMessage (actual, expected) { + const actualSnapshot = semanticSnapshotValue(actual); + const expectedSnapshot = semanticSnapshotValue(expected); + const difference = firstSemanticDifference(actualSnapshot, expectedSnapshot); + if (!difference) return 'unknown semantic difference'; + const printable = value => { + const printed = JSON.stringify(value) ?? String(value); + return printed.length > 160 ? `${printed.slice(0, 157)}...` : printed; + }; + let context = ''; + const finalSegment = difference.path[difference.path.length - 1]; + if (finalSegment === 'id' || finalSegment === 'children') { + const nodePath = difference.path.slice(0, -1); + const valueAt = (root, path) => path.reduce((value, key) => value?.[key], root); + const actualNode = valueAt(actualSnapshot, nodePath); + const expectedNode = valueAt(expectedSnapshot, nodePath); + context = ` (nodes ${printable({ + name: actualNode?.name, + children: actualNode?.children?.map(child => child.name) + })}, expected ${printable({ + name: expectedNode?.name, + children: expectedNode?.children?.map(child => child.name) + })}; root children ${printable(actualSnapshot.root.children.map(child => child.name))}, expected ${ + printable(expectedSnapshot.root.children.map(child => child.name))})`; + } + return `${difference.path.join('.')} is ${printable(difference.left)}, expected ${printable(difference.right)}${context}`; +} + +export function alignParsedDocumentIdentities (parsedDocument, expectedDocument) { + const idMap = new Map(); + const alignNode = (parsedNode, expectedNode) => { + if (parsedNode.name !== expectedNode.name) return null; + const expectedByName = new Map(expectedNode.children.map(child => [child.name, child])); + const children = []; + idMap.set(parsedNode.id, expectedNode.id); + for (const parsedChild of parsedNode.children) { + const expectedChild = expectedByName.get(parsedChild.name); + children.push(expectedChild + ? alignNode(parsedChild, expectedChild) || parsedChild + : parsedChild); + } + return new ComponentNode({ + id: expectedNode.id, + name: parsedNode.name, + provenance: parsedNode.provenance, + partComponent: parsedNode.partComponent, + typeExpression: parsedNode.typeExpression, + properties: parsedNode.properties, + children + }); + }; + const initiallyAlignedRoot = alignNode(parsedDocument.root, expectedDocument.root); + if (!initiallyAlignedRoot) return parsedDocument; + const remapOrderingReferences = node => new ComponentNode({ + id: node.id, + name: node.name, + provenance: node.provenance.beforeId + ? { ...node.provenance, beforeId: idMap.get(node.provenance.beforeId) || node.provenance.beforeId } + : node.provenance, + partComponent: node.partComponent, + typeExpression: node.typeExpression, + properties: node.properties, + children: node.children.map(remapOrderingReferences) + }); + const root = remapOrderingReferences(initiallyAlignedRoot); + const remapNodeRecords = records => Object.freeze(Object.fromEntries( + Object.entries(records || {}).map(([nodeId, value]) => [idMap.get(nodeId) || nodeId, value]) + )); + return new ComponentDocument({ + revision: parsedDocument.revision, + componentId: parsedDocument.componentId, + moduleId: parsedDocument.moduleId, + exportName: parsedDocument.exportName, + parentComponent: parsedDocument.parentComponent, + root, + layoutModels: parsedDocument.layoutModels.map(model => ({ + ...model, + ownerId: idMap.get(model.ownerId) || model.ownerId, + references: model.references.map(reference => ({ + ...reference, + targetId: idMap.get(reference.targetId) || reference.targetId + })) + })), + sourceMetadata: { + ...parsedDocument.sourceMetadata, + nodeIdToAstLocation: remapNodeRecords( + parsedDocument.sourceMetadata.nodeIdToAstLocation + ), + nodeSpecLocations: remapNodeRecords( + parsedDocument.sourceMetadata.nodeSpecLocations + ), + propertyLocations: remapNodeRecords( + parsedDocument.sourceMetadata.propertyLocations + ), + originalExpressions: remapNodeRecords( + parsedDocument.sourceMetadata.originalExpressions + ), + suppressionLocations: remapNodeRecords( + parsedDocument.sourceMetadata.suppressionLocations + ), + suppressionLocationLists: Object.freeze(Object.fromEntries( + Object.entries(parsedDocument.sourceMetadata.suppressionLocationLists || {}) + .map(([nodeId, locations]) => [ + idMap.get(nodeId) || nodeId, + Object.freeze(locations.map(location => ({ ...location }))) + ]) + )), + orderingLocations: remapNodeRecords( + parsedDocument.sourceMetadata.orderingLocations + ), + layoutReferenceLocations: Object.freeze(Object.fromEntries( + Object.entries(parsedDocument.sourceMetadata.layoutReferenceLocations || {}) + .map(([ownerId, locations]) => [ + idMap.get(ownerId) || ownerId, + Object.freeze(Object.fromEntries(Object.entries(locations).map( + ([targetId, location]) => [idMap.get(targetId) || targetId, location] + ))) + ]) + )) + } + }); +} + +export function componentDocumentsSemanticallyEqual (left, right) { + return left instanceof ComponentDocument && right instanceof ComponentDocument && + semanticSnapshot(left) === semanticSnapshot(right); +} + +function documentWithRevision (document, revision) { + return new ComponentDocument({ + revision, + componentId: document.componentId, + moduleId: document.moduleId, + exportName: document.exportName, + parentComponent: document.parentComponent, + root: document.root, + layoutModels: document.layoutModels, + sourceMetadata: document.sourceMetadata + }); +} + +function documentWithLayoutModels (document, layoutModels) { + return new ComponentDocument({ + revision: document.revision, + componentId: document.componentId, + moduleId: document.moduleId, + exportName: document.exportName, + parentComponent: document.parentComponent, + root: document.root, + layoutModels, + sourceMetadata: document.sourceMetadata + }); +} + +function relocatedRange (range, replacedRange, delta) { + if (!range) return range; + if (range.end <= replacedRange.start) return range; + if (range.start >= replacedRange.end) { + return Object.freeze({ start: range.start + delta, end: range.end + delta }); + } + if (range.start <= replacedRange.start && range.end >= replacedRange.end) { + return Object.freeze({ start: range.start, end: range.end + delta }); + } + return null; +} + +function relocatedRangeRecord (record, replacedRange, delta) { + const relocated = {}; + for (const [key, range] of Object.entries(record || {})) { + const nextRange = relocatedRange(range, replacedRange, delta); + if (!nextRange) return null; + relocated[key] = nextRange; + } + return relocated; +} + +function relocatedRangeListRecord (record, replacedRange, delta) { + const relocated = {}; + for (const [key, ranges] of Object.entries(record || {})) { + const nextRanges = ranges.map(range => relocatedRange(range, replacedRange, delta)); + if (nextRanges.some(range => !range)) return null; + relocated[key] = nextRanges; + } + return relocated; +} + +function incrementallyProjectedTextDocument ({ + beforeDocument, + reduction, + change, + valueSource +}) { + const { semanticDelta } = reduction; + const isTextReplacement = semanticDelta.kind === ComponentSemanticDeltaKind.TEXT_EDITED || + (semanticDelta.kind === ComponentSemanticDeltaKind.PROPERTY_SET && + semanticDelta.property === 'textAndAttributes'); + if (!isTextReplacement || change?.action !== 'replace') return null; + const replacedRange = { start: change.start, end: change.end }; + const delta = valueSource.length - (change.end - change.start); + const metadata = beforeDocument.sourceMetadata; + const componentRange = relocatedRange(metadata.componentRange, replacedRange, delta); + const declarationRange = relocatedRange(metadata.declarationRange, replacedRange, delta); + const specRange = relocatedRange(metadata.specRange, replacedRange, delta); + const nodeIdToAstLocation = relocatedRangeRecord( + metadata.nodeIdToAstLocation, + replacedRange, + delta + ); + const nodeSpecLocations = relocatedRangeRecord( + metadata.nodeSpecLocations, + replacedRange, + delta + ); + const suppressionLocations = relocatedRangeRecord( + metadata.suppressionLocations, + replacedRange, + delta + ); + const suppressionLocationLists = relocatedRangeListRecord( + metadata.suppressionLocationLists, + replacedRange, + delta + ); + const orderingLocations = relocatedRangeRecord( + metadata.orderingLocations, + replacedRange, + delta + ); + if (!componentRange || !declarationRange || !specRange || + !nodeIdToAstLocation || !nodeSpecLocations || + !suppressionLocations || !suppressionLocationLists || !orderingLocations) return null; + + const propertyLocations = {}; + for (const [nodeId, properties] of Object.entries(metadata.propertyLocations || {})) { + propertyLocations[nodeId] = {}; + for (const [property, location] of Object.entries(properties)) { + const outer = relocatedRange(location, replacedRange, delta); + const value = relocatedRange(location.value, replacedRange, delta); + if (!outer || !value) return null; + propertyLocations[nodeId][property] = { ...outer, value }; + } + } + + const layoutReferenceLocations = {}; + for (const [ownerId, references] of Object.entries(metadata.layoutReferenceLocations || {})) { + layoutReferenceLocations[ownerId] = {}; + for (const [targetId, location] of Object.entries(references)) { + const target = relocatedRange(location.target, replacedRange, delta); + const entry = relocatedRange(location.entry, replacedRange, delta); + if (!target || !entry) return null; + layoutReferenceLocations[ownerId][targetId] = { ...location, target, entry }; + } + } + + const importInsertionIndex = metadata.importInsertionIndex >= replacedRange.end + ? metadata.importInsertionIndex + delta + : metadata.importInsertionIndex; + if (importInsertionIndex > replacedRange.start && + importInsertionIndex < replacedRange.end) return null; + const originalExpressions = Object.fromEntries(Object.entries( + metadata.originalExpressions || {} + ).map(([nodeId, properties]) => [nodeId, { ...properties }])); + originalExpressions[semanticDelta.nodeId] = { + ...originalExpressions[semanticDelta.nodeId], + textAndAttributes: valueSource + }; + return new ComponentDocument({ + revision: reduction.document.revision, + componentId: reduction.document.componentId, + moduleId: reduction.document.moduleId, + exportName: reduction.document.exportName, + parentComponent: reduction.document.parentComponent, + root: reduction.document.root, + layoutModels: reduction.document.layoutModels, + sourceMetadata: { + ...metadata, + componentRange, + declarationRange, + specRange, + nodeIdToAstLocation, + nodeSpecLocations, + propertyLocations, + originalExpressions, + suppressionLocations, + suppressionLocationLists, + orderingLocations, + layoutReferenceLocations, + importInsertionIndex + } + }); +} + +export function projectComponentSource ({ source, beforeDocument, reduction }) { + if (typeof source !== 'string') throw new Error('Source projection requires source text'); + if (!(beforeDocument instanceof ComponentDocument)) { + throw new Error('Source projection requires the previous ComponentDocument'); + } + if (!(reduction?.document instanceof ComponentDocument)) { + throw new Error('Source projection requires a component reduction result'); + } + + const { semanticDelta } = reduction; + const diagnostics = []; + let change = null; + let structuralChanges = null; + let requiredBindings = semanticDelta.requiredBindings || []; + if (semanticDelta.kind === ComponentSemanticDeltaKind.PROPERTY_SET) { + const entry = reduction.document.root && + findNodeEntry(reduction.document.root, semanticDelta.nodeId, semanticDelta.property); + const valueSource = propertyEntrySource(entry); + if (typeof valueSource !== 'string') { + diagnostics.push(diagnostic( + ComponentSourceProjectionDiagnosticKind.UNSUPPORTED_EXPLICIT_VALUE, + `Cannot serialize ${semanticDelta.nodeId}.${semanticDelta.property}` + )); + } else { + const location = beforeDocument.sourceMetadata + .propertyLocations?.[semanticDelta.nodeId]?.[semanticDelta.property]?.value; + change = location + ? Object.freeze({ + action: 'replace', + start: location.start, + end: location.end, + text: valueSource + }) + : insertPropertyChange( + source, + beforeDocument, + semanticDelta.nodeId, + semanticDelta.property, + valueSource + ); + } + } else if (semanticDelta.kind === ComponentSemanticDeltaKind.PROPERTY_CLEARED) { + change = removePropertyChange( + source, + beforeDocument, + semanticDelta.nodeId, + semanticDelta.property + ); + } else if (semanticDelta.kind === ComponentSemanticDeltaKind.NODE_RENAMED) { + const node = findComponentNode(beforeDocument, semanticDelta.nodeId); + const nameLocation = beforeDocument.sourceMetadata + .propertyLocations?.[semanticDelta.nodeId]?.name?.value; + let nameChange = nameLocation + ? Object.freeze({ + action: 'replace', + start: nameLocation.start, + end: nameLocation.end, + text: JSON.stringify(semanticDelta.after) + }) + : insertPropertyChange( + source, + beforeDocument, + semanticDelta.nodeId, + 'name', + JSON.stringify(semanticDelta.after) + ); + const requiresInheritedReplacement = + node?.provenance.kind === ComponentNodeProvenanceKind.INHERITED && + semanticDelta.after !== (node.provenance.baseName || node.name); + if (nameChange && requiresInheritedReplacement) { + nameChange = inheritedRenameChange(source, beforeDocument, node, nameChange); + requiredBindings = [...requiredBindings, componentImportBinding({ + kind: ComponentImportKind.NAMED, + moduleId: 'lively.morphic/components/core.js', + imported: 'replace', + local: 'replace' + })]; + } + const layoutChanges = renameLayoutReferenceChanges( + beforeDocument, + semanticDelta.nodeId, + semanticDelta.after + ); + const orderingChanges = renameOrderingReferenceChanges( + source, + beforeDocument, + reduction.document, + semanticDelta.nodeId + ); + if (!nameChange) { + diagnostics.push(diagnostic( + ComponentSourceProjectionDiagnosticKind.MISSING_SOURCE_METADATA, + `The renamed node ${semanticDelta.nodeId} has no writable source location` + )); + } else if (layoutChanges === null || orderingChanges === null) { + diagnostics.push(diagnostic( + ComponentSourceProjectionDiagnosticKind.MISSING_SOURCE_METADATA, + `The owner layout or ordering dependants for ${semanticDelta.nodeId} cannot be projected safely` + )); + } else { + structuralChanges = [nameChange, ...layoutChanges, ...orderingChanges]; + } + } else if (semanticDelta.kind === ComponentSemanticDeltaKind.TEXT_EDITED) { + const entry = findNodeEntry( + reduction.document.root, + semanticDelta.nodeId, + 'textAndAttributes' + ); + const valueSource = propertyEntrySource(entry); + const location = beforeDocument.sourceMetadata + .propertyLocations?.[semanticDelta.nodeId]?.textAndAttributes?.value; + if (typeof valueSource !== 'string') { + diagnostics.push(diagnostic( + ComponentSourceProjectionDiagnosticKind.UNSUPPORTED_EXPLICIT_VALUE, + `Cannot serialize ${semanticDelta.nodeId}.textAndAttributes` + )); + } else if (location) { + change = Object.freeze({ + action: 'replace', + start: location.start, + end: location.end, + text: valueSource + }); + } else { + change = insertPropertyChange( + source, + beforeDocument, + semanticDelta.nodeId, + 'textAndAttributes', + valueSource + ); + } + } else if (semanticDelta.kind === ComponentSemanticDeltaKind.NODE_MOVED) { + if (semanticDelta.inheritanceTransition === + ComponentMoveInheritanceTransitionKind.MATERIALIZE) { + const materializedNode = findComponentNode( + reduction.document, + semanticDelta.nodeId + ); + const suppression = suppressInheritedNodeChange(source, beforeDocument, { + nodeId: semanticDelta.inheritedNodeId, + parentId: semanticDelta.fromParentId + }); + const nodeSource = materializedNode && componentNodeSource( + materializedNode, + reduction.document + ); + const introduction = typeof nodeSource === 'string' && insertMovedNodeChange( + source, + beforeDocument, + semanticDelta.toParentId, + semanticDelta.toIndex, + nodeSource + ); + const layoutChanges = removeLayoutReferenceChanges( + beforeDocument, + semanticDelta.inheritedNodeId + ); + if (!suppression || !introduction || layoutChanges === null) { + diagnostics.push(diagnostic( + ComponentSourceProjectionDiagnosticKind.MISSING_SOURCE_METADATA, + `The inherited move ${semanticDelta.inheritedNodeId} cannot be materialized in source` + )); + } else { + structuralChanges = [suppression, introduction, ...layoutChanges]; + requiredBindings = [...requiredBindings, componentImportBinding({ + kind: ComponentImportKind.NAMED, + moduleId: 'lively.morphic/components/core.js', + imported: 'without', + local: 'without' + }), componentImportBinding({ + kind: ComponentImportKind.NAMED, + moduleId: 'lively.morphic', + imported: 'add', + local: 'add' + })]; + } + } else if (semanticDelta.inheritanceTransition === + ComponentMoveInheritanceTransitionKind.RESTORE) { + const removal = removeNodeChange( + source, + beforeDocument, + semanticDelta.nodeId, + semanticDelta.fromParentId + ); + const restorations = restoreInheritedNodeChanges(source, beforeDocument, { + nodeId: semanticDelta.inheritedNodeId + }); + const consolidation = consolidateRestoredNodeChange( + source, + beforeDocument, + reduction.document, + semanticDelta + ); + if (!removal || !restorations || !consolidation) { + diagnostics.push(diagnostic( + ComponentSourceProjectionDiagnosticKind.MISSING_SOURCE_METADATA, + `The inherited move ${semanticDelta.inheritedNodeId} cannot be restored in source` + )); + } else { + structuralChanges = [removal, ...restorations, ...consolidation]; + } + } else if (semanticDelta.fromParentId !== semanticDelta.toParentId) { + const reparentChanges = reparentNodeChanges( + source, + beforeDocument, + reduction.document, + semanticDelta + ); + const layoutChanges = removeLayoutReferenceChanges( + beforeDocument, + semanticDelta.nodeId + ); + if (!reparentChanges) { + diagnostics.push(diagnostic( + ComponentSourceProjectionDiagnosticKind.MISSING_SOURCE_METADATA, + `The moved node ${semanticDelta.nodeId} has no writable source locations` + )); + } else if (layoutChanges === null) { + diagnostics.push(diagnostic( + ComponentSourceProjectionDiagnosticKind.MISSING_SOURCE_METADATA, + `The former owner layout for ${semanticDelta.nodeId} cannot be projected safely` + )); + } else { + structuralChanges = [...reparentChanges, ...layoutChanges]; + } + } else { + change = reorderNodeChange( + source, + beforeDocument, + reduction.document, + semanticDelta.fromParentId, + semanticDelta.nodeId + ); + } + } else if (semanticDelta.kind === ComponentSemanticDeltaKind.NODE_INTRODUCED) { + const node = findComponentNode(reduction.document, semanticDelta.nodeId); + const nodeSource = node && componentNodeSource(node, reduction.document); + if (typeof nodeSource !== 'string') { + diagnostics.push(diagnostic( + ComponentSourceProjectionDiagnosticKind.UNSUPPORTED_EXPLICIT_VALUE, + `Cannot serialize introduced component node ${semanticDelta.nodeId}` + )); + } else { + change = insertMovedNodeChange( + source, + beforeDocument, + semanticDelta.parentId, + semanticDelta.index, + nodeSource + ); + } + } else if (semanticDelta.kind === ComponentSemanticDeltaKind.NODE_REMOVED) { + const removedNode = findComponentNode(beforeDocument, semanticDelta.nodeId); + const formerParent = removedNode && findComponentParent( + beforeDocument, + semanticDelta.nodeId + ); + const orderingDependants = formerParent?.children.filter(child => + child.provenance.kind === ComponentNodeProvenanceKind.ADDED && + child.provenance.beforeId === semanticDelta.nodeId) || []; + const nodeChange = removeNodeChange( + source, + beforeDocument, + semanticDelta.nodeId, + semanticDelta.parentId + ); + const layoutChanges = removeLayoutReferenceChanges( + beforeDocument, + semanticDelta.nodeId + ); + const orderingChanges = orderingDependants.map(child => + orderingRewriteChange( + source, + beforeDocument, + reduction.document, + child.id + )); + if (!nodeChange) { + diagnostics.push(diagnostic( + ComponentSourceProjectionDiagnosticKind.MISSING_SOURCE_METADATA, + `The removed node ${semanticDelta.nodeId} has no writable source location` + )); + } else if (layoutChanges === null || orderingChanges.some(change => !change)) { + diagnostics.push(diagnostic( + ComponentSourceProjectionDiagnosticKind.MISSING_SOURCE_METADATA, + `The owner layout or ordering dependants for ${semanticDelta.nodeId} cannot be projected safely` + )); + } else { + structuralChanges = [nodeChange, ...orderingChanges, ...layoutChanges]; + } + } else if (semanticDelta.kind === ComponentSemanticDeltaKind.NODE_SUPPRESSED) { + change = suppressInheritedNodeChange(source, beforeDocument, semanticDelta); + } else if (semanticDelta.kind === ComponentSemanticDeltaKind.NODE_RESTORED) { + structuralChanges = restoreInheritedNodeChanges(source, beforeDocument, semanticDelta); + } else { + diagnostics.push(diagnostic( + ComponentSourceProjectionDiagnosticKind.UNSUPPORTED_DELTA, + `Component source projection does not support ${semanticDelta.kind}`, + { semanticDeltaKind: semanticDelta.kind } + )); + } + + if (!change && !structuralChanges && !diagnostics.length) { + diagnostics.push(diagnostic( + ComponentSourceProjectionDiagnosticKind.MISSING_SOURCE_METADATA, + 'The semantic change has no matching source location' + )); + } + if (semanticDelta.kind === ComponentSemanticDeltaKind.NODE_SUPPRESSED) { + requiredBindings = [...requiredBindings, componentImportBinding({ + kind: ComponentImportKind.NAMED, + moduleId: 'lively.morphic/components/core.js', + imported: 'without', + local: 'without' + })]; + } + const importChanges = diagnostics.length + ? [] + : importChangesFor( + source, + beforeDocument, + requiredBindings, + diagnostics + ); + if (diagnostics.length) { + return Object.freeze({ + supported: false, + sourceBefore: source, + sourceAfter: source, + changes: Object.freeze([]), + projectedDocument: null, + diagnostics: Object.freeze(diagnostics) + }); + } + + const changes = [...(structuralChanges || [change]), ...importChanges]; + const sourceAfter = applyChanges(source, changes); + const textEntry = findNodeEntry( + reduction.document.root, + semanticDelta.nodeId, + 'textAndAttributes' + ); + const textValueSource = propertyEntrySource(textEntry); + if (!importChanges.length && typeof textValueSource === 'string') { + if (textEntry?.kind === ComponentPropertyKind.OPAQUE_EXPRESSION) { + try { + parse(`(${textValueSource})`); + } catch (error) { + diagnostics.push(diagnostic( + ComponentSourceProjectionDiagnosticKind.PROJECTED_SOURCE_INVALID, + 'Projected text expression could not be parsed', + { error } + )); + } + } + const projectedDocument = diagnostics.length + ? null + : incrementallyProjectedTextDocument({ + beforeDocument, + reduction, + change, + valueSource: textValueSource + }); + if (projectedDocument) { + return Object.freeze({ + supported: true, + sourceBefore: source, + sourceAfter, + changes: Object.freeze(changes), + projectedDocument, + diagnostics: Object.freeze([]) + }); + } + } + const reparsed = parseComponentSource({ + source: sourceAfter, + moduleId: beforeDocument.moduleId, + exportName: beforeDocument.exportName, + componentId: beforeDocument.componentId, + parentDocument: beforeDocument.sourceMetadata.parentDocument || null, + resolveComponentDocument: beforeDocument.sourceMetadata.resolveComponentDocument || null + }); + const alignedDocument = reparsed.supported + ? alignParsedDocumentIdentities(reparsed.document, reduction.document) + : null; + const refreshesLayoutModels = + ([ + ComponentSemanticDeltaKind.PROPERTY_SET, + ComponentSemanticDeltaKind.PROPERTY_CLEARED + ].includes(semanticDelta.kind) && semanticDelta.property === 'layout') || + [ + ComponentSemanticDeltaKind.NODE_INTRODUCED, + ComponentSemanticDeltaKind.NODE_MOVED + ].includes(semanticDelta.kind); + let expectedDocument = refreshesLayoutModels && alignedDocument + ? documentWithLayoutModels(reduction.document, alignedDocument.layoutModels) + : reduction.document; + if (semanticDelta.kind === ComponentSemanticDeltaKind.PROPERTY_SET && + alignedDocument) { + expectedDocument = documentWithProjectedPropertyEntry( + expectedDocument, + alignedDocument, + semanticDelta.nodeId, + semanticDelta.property + ); + } + if (!reparsed.supported) { + const sourceMessage = reparsed.diagnostics?.[0]?.message; + diagnostics.push(diagnostic( + ComponentSourceProjectionDiagnosticKind.PROJECTED_SOURCE_INVALID, + `Projected component source could not be parsed${sourceMessage ? `: ${sourceMessage}` : ''}`, + { sourceDiagnostics: reparsed.diagnostics } + )); + } else if (!componentDocumentsSemanticallyEqual(alignedDocument, expectedDocument)) { + diagnostics.push(diagnostic( + ComponentSourceProjectionDiagnosticKind.PROJECTED_SEMANTICS_MISMATCH, + `Projected source does not represent the reduced component document: ${ + semanticDifferenceMessage(alignedDocument, expectedDocument)}` + )); + } + + return Object.freeze({ + supported: diagnostics.length === 0, + sourceBefore: source, + sourceAfter, + changes: Object.freeze(changes), + projectedDocument: diagnostics.length + ? null + : documentWithRevision(alignedDocument, reduction.document.revision), + diagnostics: Object.freeze(diagnostics) + }); +} + +function findNodeEntry (node, nodeId, property) { + if (node.id === nodeId) return node.properties[property]; + for (const child of node.children) { + const entry = findNodeEntry(child, nodeId, property); + if (entry !== undefined) return entry; + } + return undefined; +} diff --git a/lively.ide/studio/controls/fill.cp.js b/lively.ide/studio/controls/fill.cp.js index 3c99479578..afd99a6da3 100644 --- a/lively.ide/studio/controls/fill.cp.js +++ b/lively.ide/studio/controls/fill.cp.js @@ -12,6 +12,7 @@ import { AssetBrowserPopup } from '../asset-browser.cp.js'; import { StatusMessageError } from 'lively.halos/components/messages.cp.js'; import { LabeledCheckbox } from 'lively.components/checkbox.cp.js'; +import { setMorphPropertyWithComponentCommand } from '../../components/change-tracker.js'; export class FillControlModel extends ViewModel { static get properties () { @@ -34,8 +35,10 @@ export class FillControlModel extends ViewModel { } aspectRatioChecked (checked) { - this.targetMorph.withMetaDo({ reconcileChanges: true }, () => { - this.targetMorph.sizeToAspectRatio = checked; + setMorphPropertyWithComponentCommand({ + target: this.targetMorph, + property: 'sizeToAspectRatio', + value: checked }); } @@ -171,8 +174,10 @@ export class FillControlModel extends ViewModel { if (!this.targetMorph) return; let color = this.ui.fillColorInput.colorValue; if (obj.equals(this.targetMorph.fill, color)) return; - this.targetMorph.withMetaDo({ reconcileChanges: true }, () => { - this.targetMorph.fill = color; + setMorphPropertyWithComponentCommand({ + target: this.targetMorph, + property: 'fill', + value: color }); } diff --git a/lively.ide/tests/benchmarks/reconciliation-projection-benchmark.mjs b/lively.ide/tests/benchmarks/reconciliation-projection-benchmark.mjs new file mode 100644 index 0000000000..0071d6bba7 --- /dev/null +++ b/lively.ide/tests/benchmarks/reconciliation-projection-benchmark.mjs @@ -0,0 +1,121 @@ +import { prepareShadowScalarProjection } from '../../components/reconciliation/shadow-projection.js'; +import { ComponentBridgeCommandKind } from '../../components/reconciliation/morphic-change-set-adapter.js'; +import { parseComponentSource } from '../../components/reconciliation/source-adapter.js'; + +const moduleId = 'local://projectional-reconciliation-benchmark/component.cp.js'; +const exportName = 'Example'; +const componentId = `${moduleId}#${exportName}`; + +function percentile (samples, fraction) { + const ordered = samples.slice().sort((left, right) => left - right); + return ordered[Math.min(ordered.length - 1, Math.floor(ordered.length * fraction))]; +} + +function summarize (samples) { + return { + iterations: samples.length, + meanMs: samples.reduce((sum, sample) => sum + sample, 0) / samples.length, + medianMs: percentile(samples, 0.5), + p95Ms: percentile(samples, 0.95), + maxMs: Math.max(...samples) + }; +} + +function benchmarkSource ({ fillerDeclarations = 300, textAndAttributes }) { + const filler = Array.from({ length: fillerDeclarations }, (_, index) => + `const filler${index} = { name: 'filler ${index}', value: ${index}, description: '${'x'.repeat(80)}' };` + ).join('\n'); + return `${filler} +const ${exportName} = component({ + name: 'benchmark label', + textAndAttributes: ${textAndAttributes} +});`; +} + +function runProjectionBenchmark ({ source, values, expressions = null, iterations = 240 }) { + const parsed = parseComponentSource({ source, moduleId, exportName, componentId }); + if (!parsed.supported) throw new Error(parsed.diagnostics[0]?.message || 'Benchmark source is unsupported'); + let currentSource = source; + let currentDocument = parsed.document; + const samples = []; + for (let iteration = 0; iteration < iterations; iteration++) { + const beforeIndex = iteration % values.length; + const afterIndex = (iteration + 1) % values.length; + const bridgeCommand = { + kind: ComponentBridgeCommandKind.EDIT_TEXT, + componentId, + nodeId: currentDocument.root.id, + previousValue: values[beforeIndex], + value: values[afterIndex] + }; + const started = performance.now(); + const projection = prepareShadowScalarProjection({ + source: currentSource, + moduleId, + exportName, + componentId, + bridgeCommands: [bridgeCommand], + beforeDocument: currentDocument, + resolveNodeId: () => currentDocument.root.id, + valueExpressionFor: expressions + ? () => ({ __expr__: expressions[afterIndex], bindings: {} }) + : undefined + }); + samples.push(performance.now() - started); + if (!projection.supported) { + const diagnostic = projection.diagnostics[0]; + throw new Error(`${diagnostic?.kind || 'projection failed'}: ${diagnostic?.message || ''}`); + } + currentSource = projection.sourceAfter; + currentDocument = projection.document; + } + return summarize(samples.slice(Math.min(20, Math.floor(iterations / 10)))); +} + +function runParseBenchmark ({ source, iterations = 240 }) { + const samples = []; + for (let iteration = 0; iteration < iterations; iteration++) { + const started = performance.now(); + const parsed = parseComponentSource({ source, moduleId, exportName, componentId }); + samples.push(performance.now() - started); + if (!parsed.supported) throw new Error(parsed.diagnostics[0]?.message || 'Parse failed'); + } + return summarize(samples.slice(Math.min(20, Math.floor(iterations / 10)))); +} + +const plainValues = [ + ['a'.repeat(4096), { fontWeight: 'normal' }], + ['b'.repeat(4096), { fontWeight: 'bold' }] +]; +const plainSource = benchmarkSource({ + textAndAttributes: JSON.stringify(plainValues[0]) +}); + +const results = { + sourceBytes: plainSource.length, + parse: runParseBenchmark({ source: plainSource }), + staticTextProjection: runProjectionBenchmark({ + source: plainSource, + values: plainValues + }) +}; + +class EmbeddedMorphValue {} +const embeddedValues = [ + ['before', null, new EmbeddedMorphValue(), null], + ['after', { fontWeight: 'bold' }, new EmbeddedMorphValue(), null] +]; +const embeddedExpressions = [ + `['before', null, morph({ name: 'embedded before', fill: 'red' }), null]`, + `['after', { fontWeight: 'bold' }, morph({ name: 'embedded after', fill: 'blue' }), null]` +]; +const embeddedSource = `import { morph } from 'lively.morphic';\n${benchmarkSource({ + textAndAttributes: embeddedExpressions[0] +})}`; +results.embeddedTextProjection = runProjectionBenchmark({ + source: embeddedSource, + values: embeddedValues, + expressions: embeddedExpressions +}); + +console.log(JSON.stringify(results, null, 2)); diff --git a/lively.ide/tests/components/component-core-test.js b/lively.ide/tests/components/component-core-test.js new file mode 100644 index 0000000000..bf66be3d58 --- /dev/null +++ b/lively.ide/tests/components/component-core-test.js @@ -0,0 +1,647 @@ +/* global describe, it */ +import { expect } from 'mocha-es6'; +import { + ComponentDocument, + ComponentNode, + ComponentNodeProvenanceKind, + ComponentPropertyKind, + addedNodeProvenance, + explicitProperty, + findComponentNode, + inheritedNodeProvenance, + localNodeProvenance, + resizePolicyLayoutReference, + sourceComponentReference, + tilingLayoutModel +} from '../../components/reconciliation/component-document.js'; +import { + ClearPropertyOverride, + ComponentMoveInheritanceTransitionKind, + ComponentTextEditKind, + EditText, + IntroduceNode, + MoveNode, + RemoveNode, + RenameNode, + SetOpaqueProperty, + SetMaster, + SetProperty, + SuppressInheritedNode +} from '../../components/reconciliation/commands.js'; +import { + ComponentCommandError, + ComponentSemanticDeltaKind, + reduceComponent +} from '../../components/reconciliation/reducer.js'; +import { + ComponentDocumentInvariantError, + assertComponentDocument +} from '../../components/reconciliation/invariants.js'; +import { + ComponentImportKind, + componentImportBinding, + componentImportBindingsFromExpression +} from '../../components/reconciliation/import-bindings.js'; + +function node (id, children = [], options = {}) { + return new ComponentNode({ + id, + name: options.name || id, + provenance: options.provenance || localNodeProvenance(), + properties: options.properties || {}, + partComponent: options.partComponent || null, + children + }); +} + +function documentWith (children = []) { + return new ComponentDocument({ + componentId: 'component', + moduleId: 'local://component.js', + exportName: 'Component', + root: node('root', children) + }); +} + +function commandSpec (document, nodeId) { + return { + componentId: document.componentId, + expectedRevision: document.revision, + nodeId + }; +} + +function captureError (callback) { + try { callback(); } catch (error) { return error; } + return null; +} + +describe('projectional component semantic core', () => { + it('builds immutable runtime-independent documents with explicit value variants', () => { + const child = new ComponentNode({ + id: 'child', + name: 'child', + provenance: { kind: ComponentNodeProvenanceKind.ADDED }, + properties: { + fill: { + kind: ComponentPropertyKind.EXPLICIT_VALUE, + value: { color: 'red' } + }, + master: { + kind: ComponentPropertyKind.OPAQUE_EXPRESSION, + expression: 'MyMaster' + } + } + }); + const document = documentWith([child]); + + expect(Object.isFrozen(document)).to.be.true; + expect(Object.isFrozen(document.root.children)).to.be.true; + expect(Object.isFrozen(child.provenance)).to.be.true; + expect(Object.isFrozen(child.properties.fill)).to.be.true; + expect(Object.isFrozen(child.properties.fill.value)).to.be.true; + expect(child.properties.master.expression).equals('MyMaster'); + expect(assertComponentDocument(document)).equals(document); + }); + + it('sets and clears scalar overrides with exact inverse commands', () => { + const original = documentWith([node('child')]); + const set = reduceComponent(original, SetProperty({ + ...commandSpec(original, 'child'), + property: 'fill', + value: 'green' + })); + + expect(set.document.revision).equals(1); + expect(set.document.root.children[0].properties.fill.value).equals('green'); + expect(set.semanticDelta.kind).equals(ComponentSemanticDeltaKind.PROPERTY_SET); + const restored = reduceComponent(set.document, set.inverseCommand); + expect(restored.document.root).deep.equals(original.root); + + const opaque = reduceComponent(original, SetOpaqueProperty({ + ...commandSpec(original, 'child'), + property: 'fill', + expression: 'Color.rgb(1, 2, 3)' + })); + const cleared = reduceComponent(opaque.document, ClearPropertyOverride({ + ...commandSpec(opaque.document, 'child'), + property: 'fill' + })); + expect(cleared.document.root).deep.equals(original.root); + }); + + it('validates and freezes opaque expression import requirements', () => { + const original = documentWith([node('child')]); + const bindings = componentImportBindingsFromExpression({ + 'lively.graphics': [ + 'Color', + { exported: 'pt', local: 'point' } + ] + }); + const command = SetOpaqueProperty({ + ...commandSpec(original, 'child'), + property: 'fill', + expression: 'Color.rgb(1, 2, 3)', + requiredBindings: bindings + }); + const reduction = reduceComponent(original, command); + + expect(Object.isFrozen(command.requiredBindings)).to.be.true; + expect(command.requiredBindings).deep.equals([ + componentImportBinding({ + kind: ComponentImportKind.NAMED, + moduleId: 'lively.graphics', + imported: 'Color', + local: 'Color' + }), + componentImportBinding({ + kind: ComponentImportKind.NAMED, + moduleId: 'lively.graphics', + imported: 'pt', + local: 'point' + }) + ]); + expect(reduction.semanticDelta.requiredBindings).equals(command.requiredBindings); + expect(() => SetOpaqueProperty({ + ...commandSpec(original, 'child'), + property: 'fill', + expression: 'Color.red', + requiredBindings: [ + componentImportBinding({ + kind: ComponentImportKind.NAMED, + moduleId: 'first', + imported: 'Color', + local: 'Color' + }), + componentImportBinding({ + kind: ComponentImportKind.NAMED, + moduleId: 'second', + imported: 'Color', + local: 'Color' + }) + ] + })).to.throw(/Conflicting component imports/); + }); + + it('models master changes with explicit expression dependencies and an exact inverse', () => { + const original = documentWith([node('child', [], { + properties: { master: explicitProperty({ mode: 'base' }) } + })]); + const requiredBindings = [componentImportBinding({ + kind: ComponentImportKind.NAMED, + moduleId: 'local://masters.js', + imported: 'HoverMaster', + local: 'HoverMaster' + })]; + const changed = reduceComponent(original, SetMaster({ + ...commandSpec(original, 'child'), + expression: 'HoverMaster', + requiredBindings + })); + + expect(changed.document.root.children[0].properties.master.expression) + .equals('HoverMaster'); + expect(changed.semanticDelta.requiredBindings).deep.equals(requiredBindings); + const restored = reduceComponent(changed.document, changed.inverseCommand); + expect(restored.document.root).deep.equals(original.root); + }); + + it('rejects stale revisions and leaves the original document untouched', () => { + const original = documentWith([node('child')]); + const error = captureError(() => reduceComponent(original, RenameNode({ + componentId: original.componentId, + expectedRevision: 1, + nodeId: 'child', + name: 'renamed' + }))); + + expect(error).to.be.instanceOf(ComponentCommandError); + expect(original.revision).equals(0); + expect(original.root.children[0].name).equals('child'); + }); + + it('enforces stable IDs and sibling naming invariants', () => { + const original = documentWith([node('first'), node('second')]); + const duplicateName = captureError(() => reduceComponent(original, RenameNode({ + ...commandSpec(original, 'second'), + name: 'first' + }))); + const duplicateId = captureError(() => reduceComponent(original, IntroduceNode({ + ...commandSpec(original, 'first'), + parentId: 'root', + node: node('first', [], { provenance: addedNodeProvenance() }) + }))); + + expect(duplicateName).to.be.instanceOf(ComponentDocumentInvariantError); + expect(duplicateId).to.be.instanceOf(ComponentCommandError); + }); + + it('introduces, moves, and removes nodes with inverse structural commands', () => { + const original = documentWith([ + node('source', [node('moved', [], { provenance: addedNodeProvenance() })]), + node('destination') + ]); + const moved = reduceComponent(original, MoveNode({ + ...commandSpec(original, 'moved'), + parentId: 'destination', + beforeId: null + })); + expect(moved.document.root.children[1].children[0].id).equals('moved'); + const moveRestored = reduceComponent(moved.document, moved.inverseCommand); + expect(moveRestored.document.root).deep.equals(original.root); + + const introducedNode = node('introduced', [], { provenance: addedNodeProvenance() }); + const introduced = reduceComponent(original, IntroduceNode({ + ...commandSpec(original, introducedNode.id), + parentId: 'destination', + node: introducedNode, + beforeId: null + })); + const introductionRestored = reduceComponent(introduced.document, introduced.inverseCommand); + expect(introductionRestored.document.root).deep.equals(original.root); + + const removed = reduceComponent(original, RemoveNode({ + ...commandSpec(original, 'moved') + })); + const removalRestored = reduceComponent(removed.document, removed.inverseCommand); + expect(removalRestored.document.root).deep.equals(original.root); + }); + + it('restores exact sibling order after same-parent moves', () => { + const original = documentWith([ + node('first'), + node('second'), + node('third') + ]); + const movedToFront = reduceComponent(original, MoveNode({ + ...commandSpec(original, 'second'), + parentId: 'root', + beforeId: 'first' + })); + + expect(movedToFront.document.root.children.map(child => child.id)) + .deep.equals(['second', 'first', 'third']); + const frontMoveRestored = reduceComponent( + movedToFront.document, + movedToFront.inverseCommand + ); + expect(frontMoveRestored.document.root).deep.equals(original.root); + + const movedToEnd = reduceComponent(original, MoveNode({ + ...commandSpec(original, 'first'), + parentId: 'root', + beforeId: null + })); + expect(movedToEnd.document.root.children.map(child => child.id)) + .deep.equals(['second', 'third', 'first']); + const endMoveRestored = reduceComponent( + movedToEnd.document, + movedToEnd.inverseCommand + ); + expect(endMoveRestored.document.root).deep.equals(original.root); + }); + + it('updates and restores added-node ordering anchors across parents', () => { + const original = documentWith([ + node('source', [ + node('moved', [], { provenance: addedNodeProvenance({ beforeId: 'source-tail' }) }), + node('source-tail') + ]), + node('destination', [node('destination-tail')], { + partComponent: sourceComponentReference('Destination') + }) + ]); + const moved = reduceComponent(original, MoveNode({ + ...commandSpec(original, 'moved'), + parentId: 'destination', + beforeId: 'destination-tail' + })); + + expect(findComponentNode(moved.document, 'moved').provenance.beforeId) + .equals('destination-tail'); + const restored = reduceComponent(moved.document, moved.inverseCommand); + expect(restored.document.root).deep.equals(original.root); + }); + + it('changes moved-node provenance at source ownership boundaries and restores it', () => { + const original = documentWith([ + node('part', [ + node('moved', [], { provenance: addedNodeProvenance() }) + ], { + provenance: addedNodeProvenance(), + partComponent: sourceComponentReference('Card') + }), + node('plain addition', [], { provenance: addedNodeProvenance() }) + ]); + const moved = reduceComponent(original, MoveNode({ + ...commandSpec(original, 'moved'), + parentId: 'plain addition', + beforeId: null + })); + + expect(findComponentNode(moved.document, 'moved').provenance) + .deep.equals(localNodeProvenance()); + const restored = reduceComponent(moved.document, moved.inverseCommand); + expect(restored.document.root).deep.equals(original.root); + }); + + it('materializes and exactly restores an inherited cross-parent move', () => { + const original = documentWith([ + node('part', [ + node('inherited', [], { provenance: inheritedNodeProvenance() }) + ], { partComponent: sourceComponentReference('Card') }), + node('destination', [], { partComponent: sourceComponentReference('Destination') }) + ]); + const materialized = node('materialized', [], { + name: 'inherited', + provenance: addedNodeProvenance() + }); + const moved = reduceComponent(original, MoveNode({ + ...commandSpec(original, 'inherited'), + parentId: 'destination', + beforeId: null, + runtimeFromIndex: 3, + runtimeToIndex: 5, + inheritanceTransition: { + kind: ComponentMoveInheritanceTransitionKind.MATERIALIZE, + node: materialized + } + })); + + expect(findComponentNode(moved.document, 'inherited').provenance.suppressed) + .to.be.true; + expect(findComponentNode(moved.document, 'materialized').name).equals('inherited'); + expect(moved.semanticDelta).containSubset({ + nodeId: 'materialized', + inheritedNodeId: 'inherited', + runtimeFromIndex: 3, + runtimeToIndex: 5 + }); + const restored = reduceComponent(moved.document, moved.inverseCommand); + expect(restored.document.root).deep.equals(original.root); + }); + + it('consolidates edits made while an inherited node was materialized', () => { + const inherited = node('inherited', [], { + provenance: inheritedNodeProvenance({ hasLocalOverrides: true }), + properties: { borderWidth: explicitProperty(2) } + }); + const original = documentWith([ + node('part', [inherited], { + partComponent: sourceComponentReference('Card') + }), + node('destination') + ]); + const materialized = node('materialized', [], { + name: 'inherited', + provenance: addedNodeProvenance(), + properties: { + borderWidth: explicitProperty(2), + opacity: explicitProperty(0.4) + } + }); + const moved = reduceComponent(original, MoveNode({ + ...commandSpec(original, inherited.id), + parentId: 'destination', + beforeId: null, + inheritanceTransition: { + kind: ComponentMoveInheritanceTransitionKind.MATERIALIZE, + node: materialized + } + })); + const restored = reduceComponent(moved.document, MoveNode({ + ...commandSpec(moved.document, materialized.id), + parentId: 'part', + beforeId: null, + inheritanceTransition: { + kind: ComponentMoveInheritanceTransitionKind.RESTORE, + inheritedNodeId: inherited.id + } + })); + + expect(findComponentNode(restored.document, materialized.id)).equals(null); + expect(findComponentNode(restored.document, inherited.id).properties) + .deep.equals(materialized.properties); + expect(restored.semanticDelta).containSubset({ + inheritanceTransition: ComponentMoveInheritanceTransitionKind.RESTORE, + consolidated: true, + consolidatedNodeId: inherited.id + }); + }); + + it('tracks runtime structural indices separately from suppressed semantic children', () => { + const original = documentWith([ + node('hidden', [], { + provenance: inheritedNodeProvenance({ suppressed: true }) + }), + node('first'), + node('second') + ]); + const introducedNode = node('introduced'); + const introduced = reduceComponent(original, IntroduceNode({ + ...commandSpec(original, introducedNode.id), + parentId: 'root', + node: introducedNode, + beforeId: 'first' + })); + expect(introduced.semanticDelta).containSubset({ index: 1, runtimeIndex: 0 }); + + const removed = reduceComponent(original, RemoveNode({ + ...commandSpec(original, 'first') + })); + expect(removed.semanticDelta).containSubset({ index: 1, runtimeIndex: 0 }); + + const moved = reduceComponent(original, MoveNode({ + ...commandSpec(original, 'second'), + parentId: 'root', + beforeId: 'first' + })); + expect(moved.semanticDelta).containSubset({ + fromIndex: 2, + runtimeFromIndex: 1, + toIndex: 1, + runtimeToIndex: 0 + }); + + const observedIntroduction = reduceComponent(original, IntroduceNode({ + ...commandSpec(original, 'runtime-indexed'), + parentId: 'root', + node: node('runtime-indexed'), + beforeId: null, + runtimeIndex: 7 + })); + expect(observedIntroduction.semanticDelta.runtimeIndex).equals(7); + expect(observedIntroduction.inverseCommand.runtimeIndex).equals(7); + + const observedMove = reduceComponent(original, MoveNode({ + ...commandSpec(original, 'second'), + parentId: 'root', + beforeId: 'first', + runtimeFromIndex: 6, + runtimeToIndex: 4 + })); + expect(observedMove.semanticDelta).containSubset({ + runtimeFromIndex: 6, + runtimeToIndex: 4 + }); + expect(observedMove.inverseCommand).containSubset({ + runtimeFromIndex: 4, + runtimeToIndex: 6 + }); + }); + + it('removes and exactly restores semantic owner-layout references', () => { + const original = new ComponentDocument({ + componentId: 'component', + moduleId: 'local://component.js', + exportName: 'Component', + root: node('root', [node('first'), node('second')]), + layoutModels: [tilingLayoutModel({ + ownerId: 'root', + expressionTemplate: 'new TilingLayout({ resizePolicies: })', + references: [ + resizePolicyLayoutReference({ + targetId: 'first', + expressionTemplate: '[, { width: "fill" }]' + }), + resizePolicyLayoutReference({ + targetId: 'second', + expressionTemplate: '[, { width: "fixed" }]' + }) + ] + })] + }); + const removed = reduceComponent(original, RemoveNode({ + ...commandSpec(original, 'first') + })); + + expect(removed.document.layoutModels[0].references.map(({ targetId }) => targetId)) + .deep.equals(['second']); + expect(removed.inverseCommand.parentLayoutReference).include({ index: 0 }); + const restored = reduceComponent(removed.document, removed.inverseCommand); + expect(restored.document.root).deep.equals(original.root); + expect(restored.document.layoutModels).deep.equals(original.layoutModels); + }); + + it('removes and restores layout models owned by descendants of a removed subtree', () => { + const original = new ComponentDocument({ + componentId: 'component', + moduleId: 'local://component.js', + exportName: 'Component', + root: node('root', [ + node('container', [node('layout-owner', [node('managed')])]), + node('sibling') + ]), + layoutModels: [ + tilingLayoutModel({ + ownerId: 'root', + expressionTemplate: 'new TilingLayout({ resizePolicies: })', + references: [ + resizePolicyLayoutReference({ + targetId: 'container', + expressionTemplate: '[, { width: "fill" }]' + }), + resizePolicyLayoutReference({ + targetId: 'sibling', + expressionTemplate: '[, { width: "fixed" }]' + }) + ] + }), + tilingLayoutModel({ + ownerId: 'layout-owner', + expressionTemplate: 'new TilingLayout({ resizePolicies: })', + references: [resizePolicyLayoutReference({ + targetId: 'managed', + expressionTemplate: '[, { height: "fill" }]' + })] + }) + ] + }); + const removed = reduceComponent(original, RemoveNode({ + ...commandSpec(original, 'container') + })); + + expect(removed.document.layoutModels.map(({ ownerId }) => ownerId)).deep.equals(['root']); + expect(removed.document.layoutModels[0].references.map(({ targetId }) => targetId)) + .deep.equals(['sibling']); + expect(removed.inverseCommand.subtreeLayoutModels.map(({ index, model }) => + [index, model.ownerId])).deep.equals([[1, 'layout-owner']]); + + const restored = reduceComponent(removed.document, removed.inverseCommand); + expect(restored.document.root).deep.equals(original.root); + expect(restored.document.layoutModels).deep.equals(original.layoutModels); + }); + + it('moves layout-managed nodes and restores their former policy ownership', () => { + const original = new ComponentDocument({ + componentId: 'component', + moduleId: 'local://component.js', + exportName: 'Component', + root: node('root', [node('source', [node('moved')]), node('destination')]), + layoutModels: [tilingLayoutModel({ + ownerId: 'source', + expressionTemplate: 'new TilingLayout({ resizePolicies: })', + references: [resizePolicyLayoutReference({ + targetId: 'moved', + expressionTemplate: '[, { width: "fill" }]' + })] + })] + }); + const moved = reduceComponent(original, MoveNode({ + ...commandSpec(original, 'moved'), + parentId: 'destination', + beforeId: null + })); + + expect(moved.document.layoutModels[0].references).deep.equals([]); + expect(moved.inverseCommand.parentLayoutReference).include({ index: 0 }); + const restored = reduceComponent(moved.document, moved.inverseCommand); + expect(restored.document.root).deep.equals(original.root); + expect(restored.document.layoutModels).deep.equals(original.layoutModels); + }); + + it('prevents cyclic reparenting', () => { + const original = documentWith([node('parent', [node('child')])]); + const error = captureError(() => reduceComponent(original, MoveNode({ + ...commandSpec(original, 'parent'), + parentId: 'child', + beforeId: null + }))); + + expect(error).to.be.instanceOf(ComponentCommandError); + expect(error.message).equals('Cannot move a node into its own subtree'); + }); + + it('suppresses and restores inherited nodes without deleting their identity', () => { + const inherited = node('inherited', [], { + provenance: inheritedNodeProvenance() + }); + const original = documentWith([inherited]); + const suppressed = reduceComponent(original, SuppressInheritedNode({ + ...commandSpec(original, inherited.id) + })); + + expect(suppressed.document.root.children[0].id).equals(inherited.id); + expect(suppressed.document.root.children[0].provenance.suppressed).to.be.true; + const restored = reduceComponent(suppressed.document, suppressed.inverseCommand); + expect(restored.document.root).deep.equals(original.root); + }); + + it('models text replacement as a reversible semantic command', () => { + const original = documentWith([node('text', [], { + properties: { textAndAttributes: explicitProperty('before') } + })]); + const edited = reduceComponent(original, EditText({ + ...commandSpec(original, 'text'), + operation: { + kind: ComponentTextEditKind.REPLACE_ALL, + before: 'before', + after: 'after' + } + })); + + expect(edited.document.root.children[0].properties.textAndAttributes.value).equals('after'); + const restored = reduceComponent(edited.document, edited.inverseCommand); + expect(restored.document.root).deep.equals(original.root); + }); +}); diff --git a/lively.ide/tests/components/reconciliation-test.cp.js b/lively.ide/tests/components/component-definition-test.js similarity index 85% rename from lively.ide/tests/components/reconciliation-test.cp.js rename to lively.ide/tests/components/component-definition-test.js index c7b65d129c..825daefd3a 100644 --- a/lively.ide/tests/components/reconciliation-test.cp.js +++ b/lively.ide/tests/components/component-definition-test.js @@ -3,7 +3,8 @@ import { expect } from 'mocha-es6'; import { ComponentDescriptor, morph, part, add, component } from 'lively.morphic'; import { Color, pt } from 'lively.graphics'; import { InteractiveComponentDescriptor } from '../../components/editor.js'; -import { createInitialComponentDefinition } from '../../components/reconciliation.js'; +import { createInitialComponentDefinition } from '../../components/component-definition.js'; +import { ProjectionalReconciliationUnsupportedError } from '../../components/change-tracker.js'; const prevDescr = component.DescriptorClass; component.DescriptorClass = InteractiveComponentDescriptor; @@ -46,23 +47,28 @@ const e2 = component(e1, { component.DescriptorClass = prevDescr || ComponentDescriptor; -describe('component definition reconciliation', () => { +describe('component definition editing', () => { it('component proxy includes added morphs', async () => { const c = await e2.edit(); expect(c.get('moppel')).not.to.be.null; }); - it('allows to create a component proxy for editing the spec', async () => { - // define an ad hoc component - const c = await e2.edit(); // => returns a component morph from the spec that is auto mapping changes to the spec - c._changeTracker.componentModule = null; // prevents this file from being changed + it('rejects source-less proxy edits without mutating component policy', async () => { + const c = await e2.edit(); + c._changeTracker.componentModule = null; c.withMetaDo({ reconcileChanges: true }, () => { c.get('alice').fill = Color.green; c.fill = Color.purple; }); - await c._changeTracker.onceChangesProcessed(); - expect(e2.stylePolicy.getSubSpecFor('alice').fill).to.eql(Color.green); - expect(e2.stylePolicy.spec.fill).to.eql(Color.purple); + let error; + try { + await c._changeTracker.onceChangesProcessed(); + } catch (caughtError) { + error = caughtError; + } + expect(error).to.be.instanceOf(ProjectionalReconciliationUnsupportedError); + expect(e2.stylePolicy.getSubSpecFor('alice').fill).to.eql(Color.black); + expect(e2.stylePolicy.spec.fill).to.eql(Color.yellow); }); it('allows to reify source code based on changes applied to its spec', () => { diff --git a/lively.ide/tests/components/component-projection-fuzz-test.js b/lively.ide/tests/components/component-projection-fuzz-test.js new file mode 100644 index 0000000000..300c3187e8 --- /dev/null +++ b/lively.ide/tests/components/component-projection-fuzz-test.js @@ -0,0 +1,222 @@ +/* global describe, it */ +import { expect } from 'mocha-es6'; +import { + ComponentProjectionFuzzOperationKind, + ComponentProjectionFuzzer, + DEFAULT_COMPONENT_PROJECTION_FUZZ_OPERATIONS +} from '../../components/reconciliation/component-projection-fuzzer.js'; +import { parseComponentSource } from '../../components/reconciliation/source-adapter.js'; +import { + ComponentDocument, + ComponentNode, + localNodeProvenance +} from '../../components/reconciliation/component-document.js'; +import { + alignParsedDocumentIdentities, + componentDocumentsSemanticallyEqual +} from '../../components/reconciliation/source-projector.js'; + +function runFuzzer (seed, steps) { + return runConfiguredFuzzer({ seed }, steps); +} + +function runConfiguredFuzzer (options, steps) { + try { + return new ComponentProjectionFuzzer(options).run(steps); + } catch (error) { + throw new Error(JSON.stringify({ + message: error.message, + cause: error.cause?.message, + causeStack: error.cause?.stack, + seed: error.seed ?? options.seed, + step: error.step, + operation: error.operation, + action: error.action, + actions: error.actions, + source: error.source, + layoutModels: error.layoutModels, + layoutReferenceLocations: error.layoutReferenceLocations + }, null, 2)); + } +} + +describe('component projection fuzzer', function () { + this.timeout(120000); + + it('replays deterministic semantic command sequences', () => { + const first = runFuzzer('projection replay', 24); + const second = runFuzzer('projection replay', 24); + + expect(first.actions).to.eql(second.actions); + expect(first.source).to.equal(second.source); + expect(first.runtime).to.eql(second.runtime); + expect(componentDocumentsSemanticallyEqual(first.document, second.document)).to.be.true; + }); + + it('keeps reducer, source, runtime, inverse commands, and transaction replay aligned', () => { + const seeds = [0x51CA1A, 0xC0FFEE, 0xBAD5EED, 0xDEC0DE]; + const coveredOperations = new Set(); + const operationCounts = new Map(); + + for (const seed of seeds) { + const result = runFuzzer(seed, 32); + const reparsed = parseComponentSource({ + source: result.source, + moduleId: result.document.moduleId, + exportName: result.document.exportName, + componentId: result.document.componentId + }); + expect(reparsed.supported).to.be.true; + expect(componentDocumentsSemanticallyEqual( + alignParsedDocumentIdentities(reparsed.document, result.document), + result.document + )).to.be.true; + result.actions.forEach(({ operation }) => { + coveredOperations.add(operation); + operationCounts.set(operation, (operationCounts.get(operation) || 0) + 1); + }); + } + + for (const operation of DEFAULT_COMPONENT_PROJECTION_FUZZ_OPERATIONS) { + expect( + coveredOperations.has(operation), + `expected semantic fuzz operation ${operation} to run` + ).to.be.true; + } + expect(operationCounts.get(ComponentProjectionFuzzOperationKind.INTRODUCE_FINAL_NODE)) + .to.be.greaterThan(seeds.length); + expect(operationCounts.get(ComponentProjectionFuzzOperationKind.REMOVE_FINAL_NODE)) + .to.be.greaterThan(seeds.length); + expect(operationCounts.get(ComponentProjectionFuzzOperationKind.REORDER_NODE)) + .to.be.greaterThan(0); + expect(operationCounts.get(ComponentProjectionFuzzOperationKind.REPARENT_NODE)) + .to.be.greaterThan(seeds.length); + }); + + it('sustains repeated cross-parent moves without losing subtree identity', () => { + const seeds = ['reparent-a', 'reparent-b', 'reparent-c', 'reparent-d']; + for (const seed of seeds) { + const result = new ComponentProjectionFuzzer({ + seed, + operations: [ComponentProjectionFuzzOperationKind.REPARENT_NODE] + }).run(128); + expect(result.actions).to.have.length(128); + expect(result.actions.every(({ operation }) => + operation === ComponentProjectionFuzzOperationKind.REPARENT_NODE)).to.be.true; + } + }); + + it('stress tests structural edits through nested modeled tiling layouts', () => { + const source = `const Subject = component({ + name: 'layout subject', + layout: new TilingLayout({ + resizePolicies: [ + ['first', { height: 'fixed', width: 'fill' }], + ['group', { height: 'fill', width: 'fixed' }], + ['last', { height: 'fixed', width: 'fixed' }] + ] + }), + submorphs: [{ + name: 'first' + }, { + name: 'group', + layout: new TilingLayout({ + resizePolicies: [ + ['nested first', { height: 'fill', width: 'fill' }], + ['nested last', { height: 'fixed', width: 'fill' }] + ] + }), + submorphs: [{ name: 'nested first' }, { name: 'nested last' }] + }, { + name: 'last' + }] +});`; + const operations = [ + ComponentProjectionFuzzOperationKind.RENAME_NODE, + ComponentProjectionFuzzOperationKind.INTRODUCE_FINAL_NODE, + ComponentProjectionFuzzOperationKind.REMOVE_FINAL_NODE, + ComponentProjectionFuzzOperationKind.REORDER_NODE, + ComponentProjectionFuzzOperationKind.REPARENT_NODE + ]; + + for (const seed of ['layout-structure-a', 'layout-structure-b', 'layout-structure-c']) { + const result = runConfiguredFuzzer({ seed, source, operations }, 192); + expect(result.actions).to.have.length(192); + expect(new Set(result.actions.map(({ operation }) => operation)).size) + .equals(operations.length); + } + }); + + it('alternates inherited suppression and restoration without losing identity', () => { + const parentDocument = new ComponentDocument({ + componentId: 'parent', + moduleId: 'local://component-projection-fuzz/parent.cp.js', + exportName: 'Parent', + root: new ComponentNode({ + id: 'parent-root', name: 'parent', provenance: localNodeProvenance(), + children: [new ComponentNode({ + id: 'inherited-first', name: 'inherited first', provenance: localNodeProvenance() + }), new ComponentNode({ + id: 'inherited-second', name: 'inherited second', provenance: localNodeProvenance() + })] + }) + }); + const source = `const Subject = component(Parent, { name: 'derived subject' });`; + const operations = [ + ComponentProjectionFuzzOperationKind.SUPPRESS_INHERITED_NODE, + ComponentProjectionFuzzOperationKind.RESTORE_INHERITED_NODE + ]; + + for (const seed of ['inherited-a', 'inherited-b', 'inherited-c', 'inherited-d']) { + const result = new ComponentProjectionFuzzer({ + seed, source, parentDocument, operations + }).run(256); + expect(result.actions).to.have.length(256); + expect([...new Set(result.actions.map(({ operation }) => operation))].sort()) + .deep.equals(operations.slice().sort()); + expect(result.document.root.children.map(({ id }) => id)) + .deep.equals(['inherited-first', 'inherited-second']); + } + }); + + it('fuzzes inherited visibility through resolved nested part overrides', () => { + const partDocument = new ComponentDocument({ + componentId: 'part', + moduleId: 'local://component-projection-fuzz/part.cp.js', + exportName: 'Part', + root: new ComponentNode({ + id: 'part-root', name: 'part', provenance: localNodeProvenance(), + children: [new ComponentNode({ + id: 'part-label', name: 'label', provenance: localNodeProvenance(), + children: [new ComponentNode({ + id: 'part-icon', name: 'icon', provenance: localNodeProvenance() + })] + })] + }) + }); + const source = `const Subject = component({ + name: 'subject', + submorphs: [part(Part, { + name: 'part instance', + submorphs: [{ name: 'label', submorphs: [] }] + })] +});`; + const operations = [ + ComponentProjectionFuzzOperationKind.SUPPRESS_INHERITED_NODE, + ComponentProjectionFuzzOperationKind.RESTORE_INHERITED_NODE + ]; + + for (const seed of ['nested-part-a', 'nested-part-b']) { + const result = new ComponentProjectionFuzzer({ + seed, + source, + operations, + resolveComponentDocument: ({ expression }) => + expression === 'Part' ? partDocument : null + }).run(256); + expect(result.actions).to.have.length(256); + expect(result.document.root.children[0].children[0].name).equals('label'); + expect(result.document.root.children[0].children[0].children[0].name).equals('icon'); + } + }); +}); diff --git a/lively.ide/tests/components/component-transaction-test.js b/lively.ide/tests/components/component-transaction-test.js new file mode 100644 index 0000000000..bbdc7b240f --- /dev/null +++ b/lively.ide/tests/components/component-transaction-test.js @@ -0,0 +1,537 @@ +/* global describe, it */ +import { expect } from 'mocha-es6'; +import { RenameNode, SetProperty } from '../../components/reconciliation/commands.js'; +import { + ComponentRuntimeCommitMode, + ComponentTransactionDirection, + ComponentTransactionConflictError, + ComponentTransactionPlanningDiagnosticKind, + ComponentTransactionRollbackError, + ComponentTransactionState, + ProjectionalComponentEditTransaction, + applyPreparedComponentTransaction, + commitPreparedComponentTransaction, + prepareScalarComponentTransaction +} from '../../components/reconciliation/component-transaction.js'; +import { parseComponentSource } from '../../components/reconciliation/source-adapter.js'; +import { + PreparedPolicyCachePropertyTransaction, + PreparedPolicyCacheRenameTransaction, + ProjectionalPolicyCachePropertyEditTransaction, + ProjectionalPolicyCacheEditTransaction, + applyPreparedPolicyCacheProperties, + applyPreparedPolicyCacheRenames +} from '../../components/reconciliation/policy-cache-transaction.js'; + +const moduleId = 'local://projectional-transaction-test/component.cp.js'; +const componentId = `${moduleId}#Example`; +const source = `const Example = component({ + name: 'example', + fill: 'red' +});`; + +function parsedDocument () { + return parseComponentSource({ + source, + moduleId, + exportName: 'Example', + componentId + }).document; +} + +function commandFor (document, value = 'green') { + return SetProperty({ + componentId, + expectedRevision: document.revision, + nodeId: document.root.id, + property: 'fill', + value + }); +} + +function prepare (document, options = {}) { + return prepareScalarComponentTransaction({ + id: 'component-transaction', + source, + document, + command: commandFor(document), + resolveRuntimeTargetId: () => 'runtime-root', + ...options + }); +} + +function storesFor (state, options = {}) { + return { + sourceStore: { + read: () => state.source, + write: value => { state.source = value; }, + ...options.sourceStore + }, + documentStore: { + read: () => state.document, + write: value => { state.document = value; }, + ...options.documentStore + }, + runtimeContext: { + resolveMorph: id => id === state.runtime.id ? state.runtime : null, + setMorphProperty: (morph, property, value) => { morph[property] = value; }, + ...options.runtimeContext + } + }; +} + +function captureError (callback) { + try { + callback(); + } catch (error) { + return error; + } + throw new Error('Expected callback to fail'); +} + +describe('projectional component transaction coordinator', () => { + it('prepares and atomically commits source, document, and runtime state', () => { + const document = parsedDocument(); + const preparation = prepare(document); + const state = { + source, + document, + runtime: { id: 'runtime-root', fill: 'red' } + }; + + expect(preparation.supported).to.be.true; + expect(state.source).equals(source); + expect(state.document).equals(document); + expect(state.runtime.fill).equals('red'); + + const result = commitPreparedComponentTransaction( + preparation.transaction, + storesFor(state) + ); + + expect(result.state).equals(ComponentTransactionState.COMMITTED); + expect(state.source).includes('fill: "green"'); + expect(state.document).equals(preparation.transaction.document); + expect(state.document.revision).equals(1); + expect(state.runtime.fill).equals('green'); + }); + + it('leaves every domain untouched when source planning fails', () => { + const document = parsedDocument(); + const preparation = prepareScalarComponentTransaction({ + id: 'unsupported-source-transaction', + source, + document, + command: commandFor(document, () => 'not serializable'), + resolveRuntimeTargetId: () => 'runtime-root' + }); + + expect(preparation.supported).to.be.false; + expect(preparation.transaction).equals(null); + expect(preparation.diagnostics[0].kind) + .equals(ComponentTransactionPlanningDiagnosticKind.SOURCE_PROJECTION_FAILED); + }); + + it('rejects stale document revisions during planning', () => { + const document = parsedDocument(); + const staleCommand = SetProperty({ + componentId, + expectedRevision: document.revision + 1, + nodeId: document.root.id, + property: 'fill', + value: 'green' + }); + const preparation = prepareScalarComponentTransaction({ + id: 'stale-document-transaction', + source, + document, + command: staleCommand, + resolveRuntimeTargetId: () => 'runtime-root' + }); + + expect(preparation.supported).to.be.false; + expect(preparation.transaction).equals(null); + expect(preparation.diagnostics[0].kind) + .equals(ComponentTransactionPlanningDiagnosticKind.REDUCTION_FAILED); + expect(preparation.diagnostics[0].message).includes('revision'); + }); + + it('detects stale source before mutating the document or runtime', () => { + const document = parsedDocument(); + const preparation = prepare(document); + const state = { + source: `${source}\n// concurrent edit`, + document, + runtime: { id: 'runtime-root', fill: 'red' } + }; + + const error = captureError(() => commitPreparedComponentTransaction( + preparation.transaction, + storesFor(state) + )); + + expect(error).to.be.instanceOf(ComponentTransactionConflictError); + expect(state.document).equals(document); + expect(state.runtime.fill).equals('red'); + }); + + it('validates runtime preconditions before committing source or document state', () => { + const document = parsedDocument(); + const preparation = prepare(document); + const state = { + source, + document, + runtime: { id: 'runtime-root', fill: 'blue' } + }; + + const error = captureError(() => commitPreparedComponentTransaction( + preparation.transaction, + storesFor(state) + )); + + expect(error.message).includes('Precondition failed'); + expect(state.source).equals(source); + expect(state.document).equals(document); + expect(state.runtime.fill).equals('blue'); + }); + + it('restores source and document state when runtime projection fails', () => { + const document = parsedDocument(); + const preparation = prepare(document); + const state = { + source, + document, + runtime: { id: 'runtime-root', fill: 'red' } + }; + const runtimeError = new Error('runtime commit failed'); + const stores = storesFor(state, { + runtimeContext: { + setMorphProperty: () => { throw runtimeError; } + } + }); + + const error = captureError(() => commitPreparedComponentTransaction( + preparation.transaction, + stores + )); + + expect(error).equals(runtimeError); + expect(state.source).equals(source); + expect(state.document).equals(document); + expect(state.runtime.fill).equals('red'); + }); + + it('adopts an already-applied runtime change without applying it twice', () => { + const document = parsedDocument(); + const preparation = prepare(document); + const state = { + source, + document, + runtime: { id: 'runtime-root', fill: 'green' } + }; + let runtimeWrites = 0; + const stores = storesFor(state, { + runtimeContext: { + setMorphProperty: (morph, property, value) => { + runtimeWrites++; + morph[property] = value; + } + } + }); + + const result = commitPreparedComponentTransaction( + preparation.transaction, + { + ...stores, + runtimeCommitMode: ComponentRuntimeCommitMode.ADOPT_ALREADY_APPLIED + } + ); + + expect(result.runtimeCommitMode) + .equals(ComponentRuntimeCommitMode.ADOPT_ALREADY_APPLIED); + expect(runtimeWrites).equals(0); + expect(state.source).includes('fill: "green"'); + expect(state.document).equals(preparation.transaction.document); + expect(state.runtime.fill).equals('green'); + }); + + it('applies marked supplemental runtime changes while adopting direct mutations', () => { + class LayoutState {} + const layoutSource = `const Example = component({ + name: 'example', + layout: new TilingLayout({ resizePolicies: [['child', { width: 'fill' }]] }), + submorphs: [{ name: 'child' }] +});`; + const document = parseComponentSource({ + source: layoutSource, + moduleId, + exportName: 'Example', + componentId + }).document; + const child = document.root.children[0]; + const beforeLayout = new LayoutState(); + const afterLayout = new LayoutState(); + const preparation = prepareScalarComponentTransaction({ + id: 'layout-adoption', + source: layoutSource, + document, + command: RenameNode({ + componentId, + expectedRevision: document.revision, + nodeId: child.id, + name: 'renamed' + }), + resolveRuntimeTargetId: id => id === child.id + ? 'runtime-child' + : id === document.root.id ? 'runtime-root' : null, + resolveRuntimeLayout: () => ({ + ownerId: 'runtime-root', + before: beforeLayout, + after: afterLayout, + applyWhenAdopting: true + }) + }); + const runtimeRoot = { id: 'runtime-root', layout: beforeLayout }; + const runtimeChild = { id: 'runtime-child', name: 'renamed', owner: runtimeRoot }; + const state = { source: layoutSource, document }; + let layoutWrites = 0; + const result = commitPreparedComponentTransaction(preparation.transaction, { + sourceStore: { + read: () => state.source, + write: value => { + state.source = value; + // System module updates may synchronously refresh a cached component + // from its previous policy while the source is being installed. + runtimeRoot.layout = beforeLayout; + } + }, + documentStore: { + read: () => state.document, + write: value => { state.document = value; } + }, + runtimeContext: { + resolveMorph: id => id === runtimeRoot.id + ? runtimeRoot + : id === runtimeChild.id ? runtimeChild : null, + setMorphProperty: (morph, property, value) => { + if (property === 'layout') layoutWrites++; + morph[property] = value; + } + }, + runtimeCommitMode: ComponentRuntimeCommitMode.ADOPT_ALREADY_APPLIED + }); + + expect(result.runtimeCommitMode) + .equals(ComponentRuntimeCommitMode.ADOPT_ALREADY_APPLIED); + expect(layoutWrites).equals(2); + expect(runtimeRoot.layout).equals(afterLayout); + expect(runtimeChild.name).equals('renamed'); + }); + + it('reverses an adopted runtime change if a later domain cannot commit', () => { + const document = parsedDocument(); + const preparation = prepare(document); + const state = { + source, + document, + runtime: { id: 'runtime-root', fill: 'green' } + }; + const sourceError = new Error('source commit failed'); + const stores = storesFor(state, { + sourceStore: { + write: value => { + state.source = value; + if (value !== source) throw sourceError; + } + } + }); + + const error = captureError(() => commitPreparedComponentTransaction( + preparation.transaction, + { + ...stores, + runtimeCommitMode: ComponentRuntimeCommitMode.ADOPT_ALREADY_APPLIED + } + )); + + expect(error).equals(sourceError); + expect(state.source).equals(source); + expect(state.document).equals(document); + expect(state.runtime.fill).equals('red'); + }); + + it('replays a prepared component transaction backward and forward', () => { + const document = parsedDocument(); + const preparation = prepare(document); + const state = { + source, + document, + runtime: { id: 'runtime-root', fill: 'red' } + }; + const adapters = storesFor(state); + commitPreparedComponentTransaction(preparation.transaction, adapters); + const editTransaction = new ProjectionalComponentEditTransaction( + preparation.transaction, + adapters + ); + + editTransaction.reverseApply(); + expect(state.source).equals(source); + expect(state.document).equals(document); + expect(state.runtime.fill).equals('red'); + + editTransaction.apply(); + expect(state.source).includes('fill: "green"'); + expect(state.document).equals(preparation.transaction.document); + expect(state.runtime.fill).equals('green'); + }); + + it('compensates reverse source and document writes if runtime undo fails', () => { + const document = parsedDocument(); + const preparation = prepare(document); + const state = { + source, + document, + runtime: { id: 'runtime-root', fill: 'red' } + }; + let failRuntime = false; + const adapters = storesFor(state, { + runtimeContext: { + setMorphProperty: (morph, property, value) => { + if (failRuntime) throw new Error('runtime undo failed'); + morph[property] = value; + } + } + }); + commitPreparedComponentTransaction(preparation.transaction, adapters); + failRuntime = true; + + const error = captureError(() => applyPreparedComponentTransaction( + preparation.transaction, + { ...adapters, direction: ComponentTransactionDirection.REVERSE } + )); + + expect(error.message).equals('runtime undo failed'); + expect(state.source).includes('fill: "green"'); + expect(state.document).equals(preparation.transaction.document); + expect(state.runtime.fill).equals('green'); + }); + + it('reports incomplete compensation when a rollback store fails', () => { + const document = parsedDocument(); + const preparation = prepare(document); + const state = { + source, + document, + runtime: { id: 'runtime-root', fill: 'red' } + }; + const runtimeError = new Error('runtime commit failed'); + const rollbackError = new Error('source rollback failed'); + let sourceWrites = 0; + const stores = storesFor(state, { + sourceStore: { + write: value => { + sourceWrites++; + if (sourceWrites === 2) throw rollbackError; + state.source = value; + } + }, + runtimeContext: { + setMorphProperty: () => { throw runtimeError; } + } + }); + + const error = captureError(() => commitPreparedComponentTransaction( + preparation.transaction, + stores + )); + + expect(error).to.be.instanceOf(ComponentTransactionRollbackError); + expect(error.cause).equals(runtimeError); + expect(error.rollbackErrors).deep.equals([rollbackError]); + expect(state.document).equals(document); + expect(state.source).not.equals(source); + }); +}); + +describe('projectional policy cache transaction', () => { + it('replays cached policy property values by identity', () => { + const before = ['before', null]; + const after = ['after', { fontWeight: 'bold' }]; + const transaction = new PreparedPolicyCachePropertyTransaction({ + id: 'policy-text', + changes: [{ + id: 'Example#text', + property: 'textAndAttributes', + beforeValue: before, + afterValue: after + }] + }); + const state = { value: before }; + const stores = new Map([['Example#text', { + read: () => state.value, + write: value => { state.value = value; } + }]]); + + applyPreparedPolicyCacheProperties(transaction, { stores }); + const edit = new ProjectionalPolicyCachePropertyEditTransaction(transaction, stores); + expect(state.value).equals(after); + + edit.reverseApply(); + expect(state.value).equals(before); + + edit.apply(); + expect(state.value).equals(after); + }); + + it('replays cached policy renames exactly backward and forward', () => { + const transaction = new PreparedPolicyCacheRenameTransaction({ + id: 'policy-rename', + renames: [{ id: 'Example#child', beforeName: 'before', afterName: 'after' }] + }); + const state = { name: 'before' }; + const stores = new Map([['Example#child', { + read: () => state.name, + write: name => { state.name = name; } + }]]); + + applyPreparedPolicyCacheRenames(transaction, { stores }); + const edit = new ProjectionalPolicyCacheEditTransaction(transaction, stores); + expect(state.name).equals('after'); + + edit.reverseApply(); + expect(state.name).equals('before'); + + edit.apply(); + expect(state.name).equals('after'); + }); + + it('compensates earlier policy writes when a later cache write fails', () => { + const transaction = new PreparedPolicyCacheRenameTransaction({ + id: 'policy-rename-failure', + renames: [ + { id: 'base', beforeName: 'before', afterName: 'after' }, + { id: 'derived', beforeName: 'before', afterName: 'after' } + ] + }); + const state = { base: 'before', derived: 'before' }; + const stores = new Map([ + ['base', { + read: () => state.base, + write: name => { state.base = name; } + }], + ['derived', { + read: () => state.derived, + write: name => { + if (name === 'after') throw new Error('derived cache failed'); + state.derived = name; + } + }] + ]); + + const error = captureError(() => applyPreparedPolicyCacheRenames(transaction, { stores })); + + expect(error.message).equals('derived cache failed'); + expect(state).deep.equals({ base: 'before', derived: 'before' }); + }); +}); diff --git a/lively.ide/tests/components/derived-projector-test.js b/lively.ide/tests/components/derived-projector-test.js new file mode 100644 index 0000000000..8ce8db64e3 --- /dev/null +++ b/lively.ide/tests/components/derived-projector-test.js @@ -0,0 +1,553 @@ +/* global describe, it */ +import { expect } from 'mocha-es6'; +import { + IntroduceNode, + MoveNode, + RemoveNode, + RenameNode +} from '../../components/reconciliation/commands.js'; +import { + ComponentDocument, + ComponentNode, + findComponentNode, + localNodeProvenance +} from '../../components/reconciliation/component-document.js'; +import { + DerivedProjectionDiagnosticKind, + planDerivedComponentRenamePropagation, + projectDerivedComponentRename, + projectDerivedComponentStructure +} from '../../components/reconciliation/derived-projector.js'; +import { + DerivedPropagationConflictError, + PreparedDerivedPropagationTransaction, + PreparedDerivedRuntimeRenameTransaction, + ProjectionalDerivedEditTransaction, + ProjectionalDerivedRuntimeEditTransaction, + applyPreparedDerivedPropagation, + applyPreparedDerivedRuntimeRenames +} from '../../components/reconciliation/derived-transaction.js'; +import { reduceComponent } from '../../components/reconciliation/reducer.js'; + +function parentDocument () { + return new ComponentDocument({ + componentId: 'parent', + moduleId: 'local://derived-projection/parent.cp.js', + exportName: 'Parent', + root: new ComponentNode({ + id: 'parent-root', name: 'parent', provenance: localNodeProvenance(), + children: [new ComponentNode({ + id: 'container', name: 'container', provenance: localNodeProvenance(), + children: [new ComponentNode({ + id: 'target', name: 'target', provenance: localNodeProvenance() + })] + })] + }) + }); +} + +function renamedParent (document, name = 'renamed target') { + return reduceComponent(document, RenameNode({ + componentId: document.componentId, + expectedRevision: document.revision, + nodeId: 'target', + name + })).document; +} + +describe('projectional derived component projector', () => { + it('propagates rename selectors, suppressions, and ordering anchors', () => { + const beforeParentDocument = parentDocument(); + const afterParentDocument = renamedParent(beforeParentDocument); + const source = `const Derived = component(Parent, { + name: 'derived', + submorphs: [{ + name: 'container', + submorphs: [ + { name: 'target', fill: 'red' }, + without('target'), + add({ name: 'added' }, 'target') + ] + }] +});`; + const projection = projectDerivedComponentRename({ + source, + moduleId: 'local://derived-projection/derived.cp.js', + exportName: 'Derived', + beforeParentDocument, + afterParentDocument, + nodeId: 'target' + }); + const target = findComponentNode(projection.document, 'target'); + const added = findComponentNode( + projection.document, + projection.document.root.children[0].children.find(({ name }) => name === 'added').id + ); + + expect(projection.supported).to.be.true; + expect(projection.changes).to.have.length(3); + expect(projection.sourceAfter.match(/renamed target/g)).to.have.length(3); + expect(projection.sourceAfter).not.includes("'target'"); + expect(target.name).equals('renamed target'); + expect(target.provenance.suppressed).to.be.true; + expect(added.provenance.beforeId).equals(target.id); + }); + + it('propagates inherited identity without editing a source that has no selector', () => { + const beforeParentDocument = parentDocument(); + const afterParentDocument = renamedParent(beforeParentDocument); + const source = `const Derived = component(Parent, { name: 'derived' });`; + const projection = projectDerivedComponentRename({ + source, + moduleId: 'local://derived-projection/derived.cp.js', + exportName: 'Derived', + beforeParentDocument, + afterParentDocument, + nodeId: 'target' + }); + + expect(projection.supported).to.be.true; + expect(projection.sourceAfter).equals(source); + expect(projection.changes).deep.equals([]); + expect(findComponentNode(projection.document, 'target').name).equals('renamed target'); + }); + + it('propagates renames through static derived owner-layout references', () => { + const beforeParentDocument = parentDocument(); + const afterParentDocument = renamedParent(beforeParentDocument); + const source = `const Derived = component(Parent, { + name: 'derived', + submorphs: [{ + name: 'container', + layout: new TilingLayout({ + resizePolicies: [['target', { height: 'fixed', width: 'fill' }]] + }) + }] +});`; + const projection = projectDerivedComponentRename({ + source, + moduleId: 'local://derived-projection/layout.cp.js', + exportName: 'Derived', + beforeParentDocument, + afterParentDocument, + nodeId: 'target' + }); + + expect(projection.supported).to.be.true; + expect(projection.changes).length(1); + expect(projection.sourceAfter) + .includes("resizePolicies: [[\"renamed target\", { height: 'fixed', width: 'fill' }]]"); + expect(findComponentNode(projection.document, 'target').name).equals('renamed target'); + }); + + it('rejects derived rename propagation through an unmodeled owner layout', () => { + const beforeParentDocument = parentDocument(); + const afterParentDocument = renamedParent(beforeParentDocument); + const source = `const Derived = component(Parent, { + name: 'derived', + submorphs: [{ + name: 'container', + layout: new TilingLayout({ resizePolicies: policies }) + }] +});`; + const projection = projectDerivedComponentRename({ + source, + moduleId: 'local://derived-projection/dynamic-layout.cp.js', + exportName: 'Derived', + beforeParentDocument, + afterParentDocument, + nodeId: 'target' + }); + + expect(projection.supported).to.be.false; + expect(projection.sourceAfter).equals(source); + expect(projection.diagnostics[0].kind) + .equals(DerivedProjectionDiagnosticKind.SOURCE_UNSUPPORTED); + }); + + it('rejects a propagation request without a matching parent transition', () => { + const beforeParentDocument = parentDocument(); + const projection = projectDerivedComponentRename({ + source: `const Derived = component(Parent, { name: 'derived' });`, + moduleId: 'local://derived-projection/derived.cp.js', + exportName: 'Derived', + beforeParentDocument, + afterParentDocument: beforeParentDocument, + nodeId: 'missing' + }); + + expect(projection.supported).to.be.false; + expect(projection.diagnostics[0].kind) + .equals(DerivedProjectionDiagnosticKind.INVALID_PARENT_TRANSITION); + }); + + it('inherits a parent introduction without rewriting compatible derived source', () => { + const beforeParentDocument = parentDocument(); + const introduced = new ComponentNode({ + id: 'introduced', + name: 'introduced', + provenance: localNodeProvenance() + }); + const afterParentDocument = reduceComponent(beforeParentDocument, IntroduceNode({ + componentId: beforeParentDocument.componentId, + expectedRevision: beforeParentDocument.revision, + nodeId: introduced.id, + parentId: 'container', + node: introduced, + beforeId: null + })).document; + const source = `const Derived = component(Parent, { name: 'derived' });`; + const projection = projectDerivedComponentStructure({ + source, + moduleId: 'local://derived-projection/derived.cp.js', + exportName: 'Derived', + beforeParentDocument, + afterParentDocument + }); + + expect(projection.supported).to.be.true; + expect(projection.sourceAfter).equals(source); + expect(findComponentNode(projection.document, introduced.id).name).equals('introduced'); + }); + + it('inherits removals while retaining dormant derived override intent', () => { + const beforeParentDocument = parentDocument(); + const afterParentDocument = reduceComponent(beforeParentDocument, RemoveNode({ + componentId: beforeParentDocument.componentId, + expectedRevision: beforeParentDocument.revision, + nodeId: 'target' + })).document; + const plain = projectDerivedComponentStructure({ + source: `const Derived = component(Parent, { name: 'derived' });`, + moduleId: 'local://derived-projection/plain.cp.js', + exportName: 'Derived', + beforeParentDocument, + afterParentDocument + }); + const overridden = projectDerivedComponentStructure({ + source: `const Derived = component(Parent, { + name: 'derived', + submorphs: [{ name: 'container', submorphs: [{ name: 'target', fill: 'red' }] }] +});`, + moduleId: 'local://derived-projection/overridden.cp.js', + exportName: 'Derived', + beforeParentDocument, + afterParentDocument + }); + + expect(plain.supported).to.be.true; + expect(findComponentNode(plain.document, 'target')).equals(null); + const retained = findComponentNode(overridden.document, 'container:inherited:target'); + expect(overridden.supported).to.be.true; + expect(overridden.sourceAfter).includes("name: 'target', fill: 'red'"); + expect(retained.provenance.suppressed).to.be.true; + expect(retained.properties.fill.value).equals('red'); + }); + + it('removes static derived layout policies for removed parent nodes', () => { + const beforeParentDocument = parentDocument(); + const afterParentDocument = reduceComponent(beforeParentDocument, RemoveNode({ + componentId: beforeParentDocument.componentId, + expectedRevision: beforeParentDocument.revision, + nodeId: 'target' + })).document; + const source = `const Derived = component(Parent, { + name: 'derived', + submorphs: [{ + name: 'container', + layout: new TilingLayout({ + resizePolicies: [['target', { height: 'fixed', width: 'fill' }]] + }) + }] +});`; + const projection = projectDerivedComponentStructure({ + source, + moduleId: 'local://derived-projection/layout-removal.cp.js', + exportName: 'Derived', + beforeParentDocument, + afterParentDocument + }); + + expect(projection.supported).to.be.true; + expect(projection.changes).length(1); + expect(projection.sourceAfter).includes('resizePolicies: []'); + expect(projection.sourceAfter).not.includes("'target'"); + expect(projection.document.layoutModels[0].references).deep.equals([]); + }); + + it('removes static derived layout policies when parent nodes move away', () => { + const beforeParentDocument = parentDocument(); + const afterParentDocument = reduceComponent(beforeParentDocument, MoveNode({ + componentId: beforeParentDocument.componentId, + expectedRevision: beforeParentDocument.revision, + nodeId: 'target', + parentId: 'parent-root', + beforeId: null + })).document; + const source = `const Derived = component(Parent, { + name: 'derived', + submorphs: [{ + name: 'container', + layout: new TilingLayout({ + resizePolicies: [['target', { height: 'fixed', width: 'fill' }]] + }) + }] +});`; + const projection = projectDerivedComponentStructure({ + source, + moduleId: 'local://derived-projection/layout-move.cp.js', + exportName: 'Derived', + beforeParentDocument, + afterParentDocument + }); + + expect(projection.supported).to.be.true; + expect(projection.changes).length(1); + expect(projection.sourceAfter).includes('resizePolicies: []'); + expect(findComponentNode(projection.document, 'target')).not.equals(null); + }); + + it('reactivates retained derived overrides when the parent node returns', () => { + const beforeParentDocument = parentDocument(); + const removal = reduceComponent(beforeParentDocument, RemoveNode({ + componentId: beforeParentDocument.componentId, + expectedRevision: beforeParentDocument.revision, + nodeId: 'target' + })); + const restoredParentDocument = reduceComponent( + removal.document, + removal.inverseCommand + ).document; + const source = `const Derived = component(Parent, { + name: 'derived', + submorphs: [{ name: 'container', submorphs: [{ name: 'target', fill: 'red' }] }] +});`; + const dormant = projectDerivedComponentStructure({ + source, + moduleId: 'local://derived-projection/retained.cp.js', + exportName: 'Derived', + beforeParentDocument, + afterParentDocument: removal.document + }); + const restored = projectDerivedComponentStructure({ + source: dormant.sourceAfter, + moduleId: 'local://derived-projection/retained.cp.js', + exportName: 'Derived', + beforeParentDocument: removal.document, + afterParentDocument: restoredParentDocument + }); + const target = findComponentNode(restored.document, 'target'); + + expect(dormant.supported).to.be.true; + expect(restored.supported).to.be.true; + expect(target.provenance.suppressed).to.be.false; + expect(target.properties.fill.value).equals('red'); + }); + + it('clears a derived add ordering anchor removed by its parent', () => { + const beforeParentDocument = parentDocument(); + const afterParentDocument = reduceComponent(beforeParentDocument, RemoveNode({ + componentId: beforeParentDocument.componentId, + expectedRevision: beforeParentDocument.revision, + nodeId: 'target' + })).document; + const source = `const Derived = component(Parent, { + name: 'derived', + submorphs: [{ + name: 'container', + submorphs: [add({ name: 'added' }, 'target')] + }] +});`; + const projection = projectDerivedComponentStructure({ + source, + moduleId: 'local://derived-projection/ordered.cp.js', + exportName: 'Derived', + beforeParentDocument, + afterParentDocument + }); + const added = projection.document?.root.children[0].children + .find(({ name }) => name === 'added'); + + expect(projection.supported).to.be.true; + expect(projection.changes).to.have.length(1); + expect(projection.sourceAfter).includes("add({ name: 'added' })"); + expect(projection.sourceAfter).not.includes("'target'"); + expect(added.provenance.beforeId).equals(null); + }); + + it('plans recursive propagation while composing components that share a module', () => { + const beforeParentDocument = parentDocument(); + const afterParentDocument = renamedParent(beforeParentDocument); + const child = { id: 'child' }; + const sibling = { id: 'sibling' }; + const grandchild = { id: 'grandchild' }; + const sharedSource = `const Child = component(Parent, { + name: 'child', submorphs: [{ name: 'container', submorphs: [{ name: 'target' }] }] +}); +const Sibling = component(Parent, { + name: 'sibling', submorphs: [{ name: 'container', submorphs: [without('target')] }] +});`; + const descriptions = new Map([ + [child, { + source: sharedSource, + moduleId: 'local://derived-projection/shared.cp.js', + exportName: 'Child' + }], + [sibling, { + source: sharedSource, + moduleId: 'local://derived-projection/shared.cp.js', + exportName: 'Sibling' + }], + [grandchild, { + source: `const Grandchild = component(Child, { + name: 'grandchild', + submorphs: [{ name: 'container', submorphs: [add({ name: 'added' }, 'target')] }] +});`, + moduleId: 'local://derived-projection/grandchild.cp.js', + exportName: 'Grandchild' + }] + ]); + const dependants = new Map([ + ['root', [child, sibling]], + [child, [grandchild]], + [sibling, []], + [grandchild, []] + ]); + const plan = planDerivedComponentRenamePropagation({ + root: 'root', + beforeParentDocument, + afterParentDocument, + nodeId: 'target', + getDependants: descriptor => dependants.get(descriptor) || [], + describeComponent: descriptor => descriptions.get(descriptor) + }); + + expect(plan.supported).to.be.true; + expect(plan.components).to.have.length(3); + expect(plan.modules).to.have.length(2); + const sharedPlan = plan.modules.find(({ moduleId }) => moduleId.includes('shared')); + expect(sharedPlan.sourceAfter.match(/renamed target/g)).to.have.length(2); + const grandchildPlan = plan.modules.find(({ moduleId }) => moduleId.includes('grandchild')); + expect(grandchildPlan.sourceAfter).includes('"renamed target"'); + }); + + it('rejects a cyclic derivation graph without returning partial module writes', () => { + const beforeParentDocument = parentDocument(); + const afterParentDocument = renamedParent(beforeParentDocument); + const child = { id: 'child' }; + const descriptions = new Map([[child, { + source: `const Child = component(Parent, { name: 'child' });`, + moduleId: 'local://derived-projection/child.cp.js', + exportName: 'Child' + }]]); + const plan = planDerivedComponentRenamePropagation({ + root: child, + beforeParentDocument, + afterParentDocument, + nodeId: 'target', + getDependants: () => [child], + describeComponent: descriptor => descriptions.get(descriptor) + }); + + expect(plan.supported).to.be.false; + expect(plan.modules).deep.equals([]); + expect(plan.diagnostics[0].kind) + .equals(DerivedProjectionDiagnosticKind.DEPENDENCY_GRAPH_INVALID); + }); + + it('commits and replays derived module sources as one reversible edit', () => { + const values = new Map([['a', 'a0'], ['b', 'b0']]); + const stores = new Map([...values.keys()].map(moduleId => [moduleId, { + read: () => values.get(moduleId), + write: source => values.set(moduleId, source) + }])); + const transaction = new PreparedDerivedPropagationTransaction({ + id: 'derived-1', + modules: [ + { moduleId: 'a', sourceBefore: 'a0', sourceAfter: 'a1' }, + { moduleId: 'b', sourceBefore: 'b0', sourceAfter: 'b1' } + ] + }); + const edit = new ProjectionalDerivedEditTransaction(transaction, stores); + + applyPreparedDerivedPropagation(transaction, { stores }); + expect([...values.values()]).deep.equals(['a1', 'b1']); + edit.reverseApply(); + expect([...values.values()]).deep.equals(['a0', 'b0']); + edit.apply(); + expect([...values.values()]).deep.equals(['a1', 'b1']); + }); + + it('validates every derived source before writing any module', () => { + const values = new Map([['a', 'a0'], ['b', 'stale']]); + let writes = 0; + const stores = new Map([...values.keys()].map(moduleId => [moduleId, { + read: () => values.get(moduleId), + write: source => { writes++; values.set(moduleId, source); } + }])); + const transaction = new PreparedDerivedPropagationTransaction({ + id: 'derived-conflict', + modules: [ + { moduleId: 'a', sourceBefore: 'a0', sourceAfter: 'a1' }, + { moduleId: 'b', sourceBefore: 'b0', sourceAfter: 'b1' } + ] + }); + + expect(() => applyPreparedDerivedPropagation(transaction, { stores })) + .to.throw(DerivedPropagationConflictError); + expect(writes).equals(0); + expect(values.get('a')).equals('a0'); + }); + + it('rolls back earlier derived modules when a later write fails', () => { + const values = new Map([['a', 'a0'], ['b', 'b0']]); + const stores = new Map([ + ['a', { + read: () => values.get('a'), + write: source => values.set('a', source) + }], + ['b', { + read: () => values.get('b'), + write: source => { + if (source === 'b1') throw new Error('write failed'); + values.set('b', source); + } + }] + ]); + const transaction = new PreparedDerivedPropagationTransaction({ + id: 'derived-rollback', + modules: [ + { moduleId: 'a', sourceBefore: 'a0', sourceAfter: 'a1' }, + { moduleId: 'b', sourceBefore: 'b0', sourceAfter: 'b1' } + ] + }); + + expect(() => applyPreparedDerivedPropagation(transaction, { stores })) + .to.throw('write failed'); + expect([...values.values()]).deep.equals(['a0', 'b0']); + }); + + it('commits and replays cached derived runtime renames', () => { + const names = new Map([['child', 'before'], ['grandchild', 'before']]); + const stores = new Map([...names.keys()].map(id => [id, { + read: () => names.get(id), + write: name => names.set(id, name) + }])); + const transaction = new PreparedDerivedRuntimeRenameTransaction({ + id: 'derived-runtime', + renames: [...names.keys()].map(id => ({ + id, + beforeName: 'before', + afterName: 'after' + })) + }); + const edit = new ProjectionalDerivedRuntimeEditTransaction(transaction, stores); + + applyPreparedDerivedRuntimeRenames(transaction, { stores }); + expect([...names.values()]).deep.equals(['after', 'after']); + edit.reverseApply(); + expect([...names.values()]).deep.equals(['before', 'before']); + edit.apply(); + expect([...names.values()]).deep.equals(['after', 'after']); + }); +}); diff --git a/lively.ide/tests/components/direct-manipulation-reconciliation-test.js b/lively.ide/tests/components/direct-manipulation-reconciliation-test.js new file mode 100644 index 0000000000..e350fd4624 --- /dev/null +++ b/lively.ide/tests/components/direct-manipulation-reconciliation-test.js @@ -0,0 +1,438 @@ +/* global afterEach, beforeEach, describe, it, System */ +import { expect } from 'mocha-es6'; +import { createFiles, resource } from 'lively.resources'; +import module from 'lively.modules/src/module.js'; +import { Color, pt, rect } from 'lively.graphics'; +import { morph } from 'lively.morphic'; +import { parseComponentSource } from '../../components/reconciliation/source-adapter.js'; + +const testDir = 'local://projectional-direct-manipulation-test/'; +const moduleId = `${testDir}project/component.cp.js`; +const inheritedMoveBaseModuleId = `${testDir}project/inherited-move-base.cp.js`; +const inheritedMoveModuleId = `${testDir}project/inherited-move.cp.js`; +const initialSource = ` +"format esm"; +import { component, ComponentDescriptor } from 'lively.morphic/components/core.js'; +import { InteractiveComponentDescriptor } from 'lively.ide/components/editor.js'; +import { Color, pt, rect } from 'lively.graphics'; +import { morph, Text, TilingLayout } from 'lively.morphic'; + +component.DescriptorClass = InteractiveComponentDescriptor; + +const Example = component({ + name: 'example', + extent: pt(100, 100), + fill: Color.red, + submorphs: [{ + type: Text, + name: 'label', + fill: Color.yellow, + textAndAttributes: ['before', null, morph({ + name: 'embedded badge', + fill: Color.blue + }), null] + }, { + name: 'container', + submorphs: [{ + name: 'nested', + fill: Color.green + }] + }, { + name: 'date array', + layout: new TilingLayout({ + orderByIndex: true, + padding: rect(3, 0, 0, 0), + spacing: 2 + }), + submorphs: [1, 2].map(i => ({ name: 'day ' + i })) + }] +}); + +component.DescriptorClass = ComponentDescriptor; + +export { Example }; +`; + +describe('projectional component direct manipulation', function () { + let descriptor; + let editable; + let componentModule; + let inheritedMoveBaseModule; + let inheritedMoveModule; + let inheritedMoveEditable; + + beforeEach(async () => { + await createFiles(testDir, { + project: { + 'component.cp.js': initialSource, + 'package.json': '{"name":"projectional-direct-manipulation-test","main":"component.cp.js"}' + } + }); + componentModule = module(System, moduleId); + ({ Example: descriptor } = await componentModule.load()); + editable = await descriptor.edit(); + }); + + afterEach(async () => { + editable?._changeTracker?.dispose(); + inheritedMoveEditable?._changeTracker?.dispose(); + await inheritedMoveModule?.unload(); + await inheritedMoveBaseModule?.unload(); + await componentModule?.unload(); + await resource(testDir).remove(); + await System._livelyModulesTranslationCache.deleteCachedData(moduleId); + }); + + function detailedReconciliationError (error) { + const change = error.change; + const meta = Object.fromEntries(Object.entries(change?.meta || {}).map( + ([key, value]) => [key, value && typeof value === 'object' + ? value.constructor?.name || 'object' + : value] + )); + return new Error(`${error.message}; rejected change: ${JSON.stringify({ + prop: change?.prop, + selector: change?.selector, + target: change?.target?.name, + meta + })}`); + } + + async function finishDirectManipulation () { + try { + await editable._changeTracker.onceChangesProcessed(); + } catch (error) { + throw detailedReconciliationError(error); + } + await editable._changeTracker._shadowComparisonPromise; + } + + async function undo () { + try { + editable.env.undoManager.undo(); + await editable._changeTracker.onceChangesProcessed(); + } catch (error) { + throw detailedReconciliationError(error); + } + } + + async function redo () { + try { + editable.env.undoManager.redo(); + await editable._changeTracker.onceChangesProcessed(); + } catch (error) { + throw detailedReconciliationError(error); + } + } + + function currentDocument () { + const parsed = parseComponentSource({ + source: componentModule._source, + moduleId, + exportName: 'Example' + }); + expect(parsed.supported, JSON.stringify(parsed.diagnostics)).to.be.true; + return parsed.document; + } + + it('reconciles a real resize and style edit with exact undo and redo', async () => { + const label = editable.get('label'); + + editable.undoStart('resize and style component'); + editable.withMetaDo({ reconcileChanges: true }, () => { + editable.extent = pt(160, 120); + label.fill = Color.orange; + }); + await finishDirectManipulation(); + editable.undoStop(); + + expect(componentModule._source).matches(/extent:\s*pt\(160,\s*120\)/); + expect(componentModule._source).includes('fill: Color.orange'); + expect(editable.extent).equals(pt(160, 120)); + expect(label.fill).equals(Color.orange); + expect(descriptor.stylePolicy.spec.extent).equals(pt(160, 120)); + expect(descriptor.stylePolicy.getSubSpecFor('label').fill).equals(Color.orange); + + await undo(); + expect(componentModule._source).equals(initialSource); + expect(editable.extent).equals(pt(100, 100)); + expect(label.fill).equals(Color.yellow); + expect(descriptor.stylePolicy.spec.extent).equals(pt(100, 100)); + expect(descriptor.stylePolicy.getSubSpecFor('label').fill).equals(Color.yellow); + + await redo(); + expect(componentModule._source).matches(/extent:\s*pt\(160,\s*120\)/); + expect(componentModule._source).includes('fill: Color.orange'); + expect(editable.extent).equals(pt(160, 120)); + expect(label.fill).equals(Color.orange); + }); + + it('reconciles a real cross-parent move with exact undo and redo', async () => { + const container = editable.get('container'); + const nested = editable.get('nested'); + + editable.undoStart('reparent component morph'); + editable.withMetaDo({ reconcileChanges: true }, () => { + editable.addMorph(nested); + }); + await finishDirectManipulation(); + editable.undoStop(); + + expect(nested.owner).equals(editable); + expect(currentDocument().root.children.map(({ name }) => name)) + .deep.equals(['label', 'container', 'date array', 'nested']); + expect(currentDocument().root.children[1].children).deep.equals([]); + + await undo(); + expect(componentModule._source).equals(initialSource); + expect(nested.owner).equals(container); + expect(container.submorphs).includes(nested); + + await redo(); + expect(nested.owner).equals(editable); + expect(editable.submorphs).includes(nested); + expect(container.submorphs).not.includes(nested); + expect(currentDocument().root.children.map(({ name }) => name)) + .deep.equals(['label', 'container', 'date array', 'nested']); + }); + + it('restyles a materialized inherited part after move undo and redo', async () => { + await resource(inheritedMoveBaseModuleId).write(` + "format esm"; + import { component, ComponentDescriptor, part } from 'lively.morphic/components/core.js'; + import { Color } from 'lively.graphics'; + + component.DescriptorClass = ComponentDescriptor; + + const Leaf = component({ + name: 'Leaf', + fill: Color.purple, + submorphs: [{ name: 'leaf child', fill: Color.orange }] + }); + const Base = component({ + name: 'Base', + submorphs: [part(Leaf, { name: 'movable leaf' })] + }); + + export { Base, Leaf }; + `); + await resource(inheritedMoveModuleId).write(` + "format esm"; + import { + add, component, ComponentDescriptor + } from 'lively.morphic/components/core.js'; + import { + InteractiveComponentDescriptor + } from 'lively.ide/components/editor.js'; + import { Base, Leaf } from '${inheritedMoveBaseModuleId}'; + + component.DescriptorClass = InteractiveComponentDescriptor; + + const Subject = component(Base, { + name: 'Subject', + submorphs: [{ + name: 'movable leaf', + submorphs: [{ + name: 'leaf child', + borderWidth: 2 + }] + }, add({ name: 'destination' })] + }); + + component.DescriptorClass = ComponentDescriptor; + + export { Subject }; + `); + inheritedMoveBaseModule = module(System, inheritedMoveBaseModuleId); + await inheritedMoveBaseModule.load(); + inheritedMoveModule = module(System, inheritedMoveModuleId); + const { Subject } = await inheritedMoveModule.load(); + inheritedMoveEditable = await Subject.edit(); + const destination = inheritedMoveEditable.get('destination'); + const movableLeaf = inheritedMoveEditable.get('movable leaf'); + const tracker = inheritedMoveEditable._changeTracker; + + inheritedMoveEditable.undoStart('reparent inherited component part'); + try { + inheritedMoveEditable.withMetaDo({ reconcileChanges: true }, () => { + destination.addMorph(movableLeaf); + }); + await tracker.onceChangesProcessed(); + } finally { + inheritedMoveEditable.undoStop(); + } + + expect(movableLeaf.owner).equals(destination); + expect(movableLeaf.fill).equals(Color.purple); + expect(inheritedMoveModule._source).matches(/without\(["']movable leaf["']\)/); + expect(inheritedMoveModule._source).includes('destination'); + + inheritedMoveEditable.env.undoManager.undo(); + await tracker.onceChangesProcessed(); + expect(movableLeaf.owner).equals(inheritedMoveEditable); + expect(movableLeaf.fill).equals(Color.purple); + + inheritedMoveEditable.env.undoManager.redo(); + await tracker.onceChangesProcessed(); + expect(movableLeaf.owner).equals(destination); + expect(movableLeaf.fill).equals(Color.purple); + expect(Subject.derive().get('movable leaf').fill).equals(Color.purple); + + const leafChild = movableLeaf.get('leaf child'); + inheritedMoveEditable.undoStart('materialize nested inherited override'); + try { + inheritedMoveEditable.withMetaDo({ reconcileChanges: true }, () => { + inheritedMoveEditable.addMorph(leafChild); + }); + await tracker.onceChangesProcessed(); + } finally { + inheritedMoveEditable.undoStop(); + } + + expect(leafChild.owner).equals(inheritedMoveEditable); + expect(leafChild.borderWidth.top).equals(2); + + inheritedMoveEditable.env.undoManager.undo(); + await tracker.onceChangesProcessed(); + expect(leafChild.owner).equals(movableLeaf); + expect(leafChild.borderWidth.top).equals(2); + + inheritedMoveEditable.env.undoManager.redo(); + await tracker.onceChangesProcessed(); + expect(leafChild.owner).equals(inheritedMoveEditable); + expect(leafChild.borderWidth.top).equals(2); + expect(Subject.derive().get('leaf child').borderWidth.top).equals(2); + }); + + it('reconciles rich text with an embedded morph through undo and redo', async () => { + const label = editable.get('label'); + const replacement = morph({ + name: 'replacement badge', + fill: Color.orange + }); + + editable.undoStart('replace component rich text'); + label.withMetaDo({ reconcileChanges: true }, () => { + label.textAndAttributes = [ + 'after', { fontWeight: 'bold' }, + replacement, null + ]; + }); + await finishDirectManipulation(); + editable.undoStop(); + + expect(label.textString.startsWith('after')).to.be.true; + expect(label.textAndAttributes).includes(replacement); + expect(componentModule._source).matches(/["']after["']/); + expect(componentModule._source).matches(/name:\s*["']replacement badge["']/); + expect(componentModule._source).includes('fill: Color.orange'); + expect(descriptor.derive().get('replacement badge').fill).equals(Color.orange); + + await undo(); + expect(componentModule._source).equals(initialSource); + expect(label.textString.startsWith('before')).to.be.true; + expect(label.textAndAttributes.find(value => value?.isMorph)?.name) + .equals('embedded badge'); + + await redo(); + expect(label.textString.startsWith('after')).to.be.true; + expect(label.textAndAttributes.find(value => value?.isMorph)?.name) + .equals('replacement badge'); + expect(componentModule._source).matches(/name:\s*["']replacement badge["']/); + }); + + it('reconciles an interactive text replacement through its native undo group', async () => { + const label = editable.get('label'); + + label.withMetaDo({ reconcileChanges: true }, () => { + label.replace({ + start: { row: 0, column: 1 }, + end: { row: 0, column: 5 } + }, ['interactive', { fontWeight: 'bold' }]); + }); + await finishDirectManipulation(); + + expect(label.textString.startsWith('binteractive')).to.be.true; + expect(componentModule._source).matches(/["']interactive["']/); + expect(componentModule._source).includes('fontWeight'); + + await undo(); + expect(componentModule._source).equals(initialSource); + expect(label.textString.startsWith('before')).to.be.true; + + await redo(); + expect(label.textString.startsWith('binteractive')).to.be.true; + expect(componentModule._source).matches(/["']interactive["']/); + }); + + it('renames a nested introduction that collides across component scopes', async () => { + const container = editable.get('container'); + const introduced = morph({ + name: 'label', + fill: Color.orange + }); + + editable.undoStart('introduce nested component morph'); + container.withMetaDo({ reconcileChanges: true }, () => { + container.addMorph(introduced); + }); + await finishDirectManipulation(); + editable.undoStop(); + + expect(introduced.name).equals('label_1'); + expect(componentModule._source).matches(/name:\s*["']label_1["']/); + + await undo(); + expect(componentModule._source).equals(initialSource); + expect(introduced.owner).equals(null); + + await redo(); + expect(introduced.owner).equals(container); + expect(introduced.name).equals('label_1'); + expect(componentModule._source).matches(/name:\s*["']label_1["']/); + + const validationModuleId = `${testDir}project/validation.cp.js`; + const validationResource = resource(validationModuleId); + const validationModule = module(System, validationModuleId); + await validationResource.write(componentModule._source); + const { Example: validationDescriptor } = await validationModule.load(); + const cold = validationDescriptor.derive(); + expect(cold.get('label').fill).equals(Color.yellow); + expect(cold.get('container').get('label_1').fill).equals(Color.orange); + await validationModule.unload(); + await validationResource.remove(); + }); + + it('reconciles changes to a nested tiling layout through undo and redo', async () => { + const container = editable.get('date array'); + + editable.undoStart('change component tiling layout'); + container.withMetaDo({ reconcileChanges: true }, () => { + container.layout = container.layout.with({ + spacing: 7, + padding: rect(8, 4, 6, 2), + wrapSubmorphs: true + }); + }); + await finishDirectManipulation(); + editable.undoStop(); + + expect(container.layout.spacing).equals(7); + expect(container.layout.padding).equals(rect(8, 4, 6, 2)); + expect(container.layout.wrapSubmorphs).to.be.true; + expect(componentModule._source).matches(/spacing:\s*7/); + expect(componentModule._source).matches(/padding:\s*rect\(8,\s*4,\s*6,\s*2\)/); + expect(componentModule._source).matches(/wrapSubmorphs:\s*true/); + + await undo(); + expect(componentModule._source).equals(initialSource); + expect(container.layout.spacing).equals(2); + expect(container.layout.padding).equals(rect(3, 0, 0, 0)); + expect(container.layout.wrapSubmorphs).to.be.false; + + await redo(); + expect(container.layout.spacing).equals(7); + expect(container.layout.padding).equals(rect(8, 4, 6, 2)); + expect(container.layout.wrapSubmorphs).to.be.true; + }); +}); diff --git a/lively.ide/tests/components/reconciliation-bridge-test.js b/lively.ide/tests/components/reconciliation-bridge-test.js new file mode 100644 index 0000000000..8a8fcdb51e --- /dev/null +++ b/lively.ide/tests/components/reconciliation-bridge-test.js @@ -0,0 +1,4173 @@ +/* global describe, it */ +import { expect } from 'mocha-es6'; +import { + MoveMorph, + SetMorphProperty, + attachedMorph, + detachedMorph +} from 'lively.morphic/changes/index.js'; +import { MorphicChangeSet } from 'lively.morphic/changes/change-set.js'; +import { UndoManager } from 'lively.morphic/undo.js'; +import { + ComponentChangeTracker, + ProjectionalReconciliationUnsupportedError, + ProjectionalCommandDiagnosticKind, + ProjectionalRenameDiagnosticKind, + componentChangeTrackerFor, + setMorphPropertyWithComponentCommand +} from '../../components/change-tracker.js'; +import { + ComponentTransactionState, + ProjectionalComponentEditTransaction +} from '../../components/reconciliation/component-transaction.js'; +import { + ComponentBridgeCommandKind, + MorphicChangeSetAdapter +} from '../../components/reconciliation/morphic-change-set-adapter.js'; +import { + ComponentDocument, + ComponentNode, + addedNodeProvenance, + inheritedNodeProvenance, + localNodeProvenance, + opaqueProperty, + sourceComponentReference, + tilingLayoutModel +} from '../../components/reconciliation/component-document.js'; +import { + ComponentMoveInheritanceTransitionKind +} from '../../components/reconciliation/commands.js'; +import { prepareShadowScalarProjection } from '../../components/reconciliation/shadow-projection.js'; + +function changeSet (operationOrOperations, origin = 'direct-manipulation') { + const operations = Array.isArray(operationOrOperations) + ? operationOrOperations + : [operationOrOperations]; + return new MorphicChangeSet({ + id: `change-${operations.map(({ kind }) => kind).join('-')}`, + origin, + operations + }); +} + +function adapterFor (...ids) { + const targets = new Map(ids.map(id => [id, { id }])); + return { + adapter: new MorphicChangeSetAdapter({ + componentId: 'module::Component', + containsMorph: morph => targets.has(morph.id) + }), + context: { resolveMorph: id => targets.get(id) } + }; +} + +class RuntimeTilingLayoutState { + constructor (resizePolicies = []) { + this.config = { + resizePolicies: resizePolicies.map(([name, policy]) => [name, { ...policy }]) + }; + this.implicitPolicies = new Map(); + } + + copy () { + const copy = new RuntimeTilingLayoutState(this.config.resizePolicies); + copy.implicitPolicies = new Map(this.implicitPolicies); + return copy; + } + + handleRenamingOf (before, after) { + this.config.resizePolicies = this.config.resizePolicies.map(([name, policy]) => + [name === before ? after : name, policy]); + } + + onSubmorphAdded (morph) { + if (!this.config.resizePolicies.some(([name]) => name === morph.name)) { + this.implicitPolicies.set(morph, { width: 'fixed', height: 'fixed' }); + } + } + + onSubmorphRemoved (morph) { + this.implicitPolicies.delete(morph); + this.config.resizePolicies = this.config.resizePolicies + .filter(([name]) => name !== morph.name); + } + + resizePolicyFor (morph) { + return this.config.resizePolicies.find(([name]) => name === morph.name)?.[1] || + this.implicitPolicies.get(morph) || null; + } +} + +function installLayoutAwareMorphOperations (owner) { + owner.addMorphAt = function (morph, index) { + this.submorphs.splice(index, 0, morph); + morph.owner = this; + this.layout?.onSubmorphAdded(morph); + }; + owner.removeMorph = function (morph) { + const index = this.submorphs.indexOf(morph); + if (index < 0) return; + this.submorphs.splice(index, 1); + morph.owner = null; + this.layout?.onSubmorphRemoved(morph); + }; +} + +function installRemovableMorphOperation (morph) { + morph.remove = function () { + this.owner?.removeMorph(this); + }; +} + +describe('projectional reconciliation bridge', () => { + it('maps runtime insertion indices past suppressed inherited children', () => { + const moduleId = 'local://runtime-index/component.cp.js'; + const componentId = `${moduleId}#Example`; + const parentDocument = new ComponentDocument({ + componentId: 'local://runtime-index/base.cp.js#Base', + moduleId: 'local://runtime-index/base.cp.js', + exportName: 'Base', + root: new ComponentNode({ + id: 'base-root', + name: 'base', + provenance: localNodeProvenance(), + children: [ + new ComponentNode({ + id: 'hidden-child', name: 'hidden', provenance: localNodeProvenance() + }), + new ComponentNode({ + id: 'visible-child', name: 'visible', provenance: localNodeProvenance() + }) + ] + }) + }); + const source = `import { without } from 'lively.morphic'; +const Example = component(Base, { + name: 'example', + submorphs: [without('hidden'), { name: 'visible' }] +});`; + const introduced = new ComponentNode({ + id: 'introduced-semantic', + name: 'introduced', + provenance: localNodeProvenance() + }); + const projection = prepareShadowScalarProjection({ + source, + moduleId, + exportName: 'Example', + componentId, + parentDocument, + bridgeCommands: [{ + kind: ComponentBridgeCommandKind.INTRODUCE_NODE, + componentId, + nodeId: 'runtime-introduced', + parentId: 'runtime-root', + index: 0 + }], + resolveNodeId: document => document.root.id, + runtimeNodeNameFor: () => 'introduced', + introducedNodeFor: () => ({ + supported: true, + node: introduced, + bindings: {}, + requiredBindings: [] + }) + }); + + expect(projection.supported, JSON.stringify(projection.diagnostics)).to.be.true; + expect(projection.steps[0].componentCommand.beforeId).equals('visible-child'); + expect(projection.sourceAfter).matches(/import\s*\{[^}]*\badd\b[^}]*\}\s*from ['"]lively\.morphic['"]/); + expect(projection.sourceAfter).includes('add({ name: "introduced" }, "visible")'); + }); + + it('preserves added descendants when materializing an inherited move', () => { + const moduleId = 'local://materialized-descendant/component.cp.js'; + const componentId = `${moduleId}#Example`; + const parentDocument = new ComponentDocument({ + componentId: 'parent', + moduleId: 'local://materialized-descendant/parent.cp.js', + exportName: 'Parent', + root: new ComponentNode({ + id: 'parent-root', + name: 'parent', + provenance: localNodeProvenance(), + children: [new ComponentNode({ + id: 'parent-mover', + name: 'mover', + provenance: localNodeProvenance(), + children: [new ComponentNode({ + id: 'parent-base-child', + name: 'base child', + provenance: localNodeProvenance() + })] + }), new ComponentNode({ + id: 'parent-destination', + name: 'destination', + provenance: localNodeProvenance() + })] + }) + }); + const source = `import { add, part, TilingLayout, without } from 'lively.morphic'; +import { Text } from 'lively.morphic/text/morph.js'; +import { Leaf } from 'local://materialized-descendant/leaf.cp.js'; +const Example = component(Parent, { + name: 'example', + submorphs: [ + { name: 'mover', layout: new TilingLayout({ spacing: 9 }), submorphs: [add({ + name: 'local child', + type: Text, + textString: 'kept', + submorphs: [{ name: 'nested local child' }] + }, 'base child')] }, + { name: 'destination' } + ] +});`; + const projection = prepareShadowScalarProjection({ + source, + moduleId, + exportName: 'Example', + componentId, + parentDocument, + bridgeCommands: [{ + kind: ComponentBridgeCommandKind.MOVE_NODE, + componentId, + nodeId: 'runtime-mover', + previousParentId: 'runtime-root', + previousIndex: 0, + parentId: 'runtime-destination', + index: 0 + }], + resolveNodeId: document => document.root.children[0].id, + resolveDestinationParentId: document => document.root.children[1].id, + runtimeOrderingNameFor: () => null, + introducedNodeFor: () => ({ + supported: true, + node: new ComponentNode({ + id: 'materialized-mover', + name: 'mover', + provenance: localNodeProvenance(), + partComponent: sourceComponentReference('Leaf'), + children: [new ComponentNode({ + id: 'materialized-local-child', + name: 'local child', + provenance: inheritedNodeProvenance({ baseName: 'local child' }) + }), new ComponentNode({ + id: 'materialized-base-child', + name: 'base child', + provenance: inheritedNodeProvenance({ baseName: 'base child' }) + })] + }), + bindings: {}, + requiredBindings: [] + }) + }); + + expect(projection.supported, JSON.stringify(projection.diagnostics)).to.be.true; + expect(projection.sourceAfter).includes('without("mover")'); + expect(projection.sourceAfter).includes('add(part(Leaf, { name: "mover"'); + expect(projection.sourceAfter) + .includes('layout: new TilingLayout({ spacing: 9 })'); + expect(projection.document.root.children[1].children[0] + .properties.layout.expression).equals('new TilingLayout({ spacing: 9 })'); + expect(projection.sourceAfter) + .includes('add({ name: "local child", type: Text, textString: "kept", submorphs: [{ name: "nested local child" }] }, "base child")'); + expect(projection.document.root.children[1].children[0].children[0] + .provenance.kind).equals(addedNodeProvenance().kind); + expect(projection.document.root.children[1].children[0].children[1] + .provenance.baseName).equals('base child'); + }); + + it('consolidates a materialized node when it returns to its suppressed inherited slot', () => { + const moduleId = 'local://materialized-restoration/component.cp.js'; + const componentId = `${moduleId}#Example`; + const parentDocument = new ComponentDocument({ + componentId: 'parent', + moduleId: 'local://materialized-restoration/parent.cp.js', + exportName: 'Parent', + root: new ComponentNode({ + id: 'parent-root', + name: 'parent', + provenance: localNodeProvenance(), + children: [new ComponentNode({ + id: 'parent-container', + name: 'container', + provenance: localNodeProvenance(), + children: [new ComponentNode({ + id: 'parent-child', + name: 'child', + provenance: localNodeProvenance() + })] + })] + }) + }); + const source = `import { add, without } from 'lively.morphic'; +const Example = component(Parent, { + name: 'example', + submorphs: [{ + name: 'container', + submorphs: [{ name: 'child', borderWidth: 2 }, without('child')] + }, add({ name: 'child', borderWidth: 2, opacity: 0.4 })] +});`; + const projection = prepareShadowScalarProjection({ + source, + moduleId, + exportName: 'Example', + componentId, + parentDocument, + bridgeCommands: [{ + kind: ComponentBridgeCommandKind.MOVE_NODE, + componentId, + nodeId: 'runtime-child', + previousParentId: 'runtime-root', + previousIndex: 1, + parentId: 'runtime-container', + index: 0 + }], + resolveNodeId: document => document.root.children.find( + child => child.name === 'child' + ).id, + resolveDestinationParentId: document => document.root.children.find( + child => child.name === 'container' + ).id, + runtimeOrderingNameFor: () => null + }); + + expect(projection.supported, JSON.stringify(projection.diagnostics)).to.be.true; + expect(projection.steps[0].componentCommand.inheritanceTransition.kind) + .equals(ComponentMoveInheritanceTransitionKind.RESTORE); + expect(projection.sourceAfter).not.includes('without('); + expect(projection.sourceAfter).not.includes('add('); + expect(projection.sourceAfter).includes('opacity: 0.4'); + expect(projection.document.root.children).to.have.length(1); + expect(projection.document.root.children[0].children).to.have.length(1); + }); + + it('maps property and rename operations into shadow component commands', () => { + const { adapter, context } = adapterFor('target'); + const property = adapter.adapt(changeSet(new SetMorphProperty({ + targetId: 'target', property: 'fill', before: 'red', after: 'green' + })), context); + const rename = adapter.adapt(changeSet(new SetMorphProperty({ + targetId: 'target', property: 'name', before: 'before', after: 'after' + })), context); + const text = adapter.adapt(changeSet(new SetMorphProperty({ + targetId: 'target', + property: 'textAndAttributes', + before: ['before', null], + after: ['after', null] + })), context); + const master = adapter.adapt(changeSet(new SetMorphProperty({ + targetId: 'target', property: 'master', before: null, after: { mode: 'hover' } + })), context); + + expect(property.commands[0]).containSubset({ + kind: ComponentBridgeCommandKind.SET_PROPERTY, + componentId: 'module::Component', + nodeId: 'target', + property: 'fill', + previousValue: 'red', + value: 'green', + origin: 'direct-manipulation' + }); + expect(rename.commands[0]).containSubset({ + kind: ComponentBridgeCommandKind.RENAME_NODE, + previousName: 'before', + name: 'after' + }); + expect(text.commands[0]).containSubset({ + kind: ComponentBridgeCommandKind.EDIT_TEXT, + previousValue: ['before', null], + value: ['after', null] + }); + expect(master.commands[0]).containSubset({ + kind: ComponentBridgeCommandKind.SET_MASTER, + previousValue: null, + value: { mode: 'hover' } + }); + }); + + it('maps insertion, removal, and movement into structural bridge commands', () => { + const { adapter, context } = adapterFor('node', 'source', 'destination'); + const insertion = adapter.adapt(changeSet(new MoveMorph({ + morphId: 'node', + from: detachedMorph(), + to: attachedMorph({ ownerId: 'destination', index: 0 }) + })), context); + const removal = adapter.adapt(changeSet(new MoveMorph({ + morphId: 'node', + from: attachedMorph({ ownerId: 'source', index: 1 }), + to: detachedMorph() + })), context); + const movement = adapter.adapt(changeSet(new MoveMorph({ + morphId: 'node', + from: attachedMorph({ ownerId: 'source', index: 1 }), + to: attachedMorph({ ownerId: 'destination', index: 0 }) + })), context); + + expect(insertion.commands[0].kind).equals(ComponentBridgeCommandKind.INTRODUCE_NODE); + expect(removal.commands[0].kind).equals(ComponentBridgeCommandKind.REMOVE_NODE); + expect(movement.commands[0]).containSubset({ + kind: ComponentBridgeCommandKind.MOVE_NODE, + previousParentId: 'source', + previousIndex: 1, + parentId: 'destination', + index: 0 + }); + expect(movement.diagnostics).deep.equals([]); + }); + + it('suppresses source and runtime projection feedback', () => { + const { adapter, context } = adapterFor('target'); + const operation = new SetMorphProperty({ + targetId: 'target', property: 'fill', before: 'red', after: 'green' + }); + + const runtime = adapter.adapt(changeSet(operation, 'runtime-projection'), context); + const source = adapter.adapt(changeSet(operation, 'source-projection'), context); + + expect(runtime.ignoredProjection).to.be.true; + expect(source.ignoredProjection).to.be.true; + expect(runtime.commands).deep.equals([]); + expect(source.commands).deep.equals([]); + }); + + it('lets trackers suppress TextMorph-internal operations before bridging', () => { + const targets = new Map([['text', { id: 'text' }]]); + const adapter = new MorphicChangeSetAdapter({ + componentId: 'module::Component', + containsMorph: () => true, + ignoreOperation: operation => operation.property === 'document' + }); + const result = adapter.adapt(changeSet([ + new SetMorphProperty({ + targetId: 'text', property: 'document', before: null, after: {} + }), + new SetMorphProperty({ + targetId: 'text', property: 'textAndAttributes', + before: ['', null], after: ['project me', null] + }) + ]), { resolveMorph: id => targets.get(id) }); + + expect(result.commands).to.have.length(1); + expect(result.commands[0]).containSubset({ + kind: ComponentBridgeCommandKind.EDIT_TEXT, + nodeId: 'text', + value: ['project me', null] + }); + }); + + it('suppresses auto-fit Text extent without suppressing a deliberate resize', () => { + const text = { + id: 'text', isText: true, owner: null, epiMorph: false, + styleProperties: ['extent'] + }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'module::Component', + containsMorph: () => true, + ignoreOperation: (operation, context) => + tracker.ignoreCommittedTextOperation(operation, context) + }); + const result = tracker.committedChangeAdapter.adapt(changeSet([ + new SetMorphProperty({ + targetId: text.id, + property: 'extent', + before: { x: 10, y: 10 }, + after: { x: 20, y: 20 }, + metadata: { isLayoutAction: true } + }), + new SetMorphProperty({ + targetId: text.id, + property: 'extent', + before: { x: 20, y: 20 }, + after: { x: 30, y: 30 }, + metadata: { metaInteraction: true } + }), + new SetMorphProperty({ + targetId: text.id, + property: 'extent', + before: { x: 30, y: 30 }, + after: { x: 40, y: 40 } + }) + ]), { resolveMorph: id => id === text.id ? text : null }); + + expect(result.commands).to.have.length(1); + expect(result.commands[0]).containSubset({ + kind: ComponentBridgeCommandKind.SET_PROPERTY, + nodeId: text.id, + property: 'extent', + value: { x: 40, y: 40 } + }); + tracker.trackedComponent = {}; + expect(tracker.ignoreChange({ + target: text, + prop: 'extent', + prevValue: { x: 10, y: 10 }, + value: { x: 20, y: 20 }, + meta: { reconcileChanges: true, metaInteraction: true } + })).to.be.true; + expect(tracker.ignoreChange({ + target: text, + prop: 'extent', + prevValue: { x: 20, y: 20 }, + value: { x: 40, y: 40 }, + meta: { reconcileChanges: true } + })).to.be.false; + }); + + it('suppresses geometry derived inside a grouped layout gesture', () => { + const tracker = Object.create(ComponentChangeTracker.prototype); + const layoutOperation = new SetMorphProperty({ + targetId: 'owner', + property: 'layout', + before: null, + after: {} + }); + const extentOperation = new SetMorphProperty({ + targetId: 'child', + property: 'extent', + before: { x: 10, y: 10 }, + after: { x: 20, y: 20 } + }); + const layoutChange = { prop: 'layout', operation: layoutOperation }; + const extentChange = { prop: 'extent', operation: extentOperation }; + const context = { + committedChange: { changes: [layoutChange, extentChange] }, + legacyChanges: [layoutChange, extentChange], + resolveMorph: () => ({ id: 'child' }) + }; + + expect(tracker.ignoreCommittedTextOperation(extentOperation, context)).to.be.true; + expect(tracker.ignoreCommittedTextOperation(layoutOperation, context)).to.be.false; + expect(tracker.projectionalLegacyChangeCount(context)).equals(1); + }); + + it('does not turn runtime projection writes into local policy overrides', () => { + const tracker = Object.create(ComponentChangeTracker.prototype); + let writeMeta; + const target = { + id: 'target', + fill: 'red', + withMetaDo (meta, callback) { + writeMeta = meta; + return callback(); + } + }; + const runtime = tracker.runtimeProjectionContext({ + resolveMorph: id => id === target.id ? target : null + }); + + runtime.setMorphProperty(target, 'fill', 'green'); + + expect(target.fill).equals('green'); + expect(writeMeta).containSubset({ + origin: 'runtime-projection', + reconcileChanges: false, + doNotOverride: true + }); + }); + + it('accepts value-equivalent runtime state after a source refresh', () => { + class Value { + constructor (number) { this.number = number; } + equals (other) { return other?.number === this.number; } + } + const target = { id: 'target', origin: new Value(3) }; + const tracker = Object.create(ComponentChangeTracker.prototype); + const runtime = tracker.runtimeProjectionContext({ + resolveMorph: id => id === target.id ? target : null + }); + + new SetMorphProperty({ + targetId: target.id, + property: 'origin', + before: new Value(3), + after: new Value(4) + }).apply(runtime); + + expect(target.origin.number).equals(4); + }); + + it('lets the component tracker retain bounded shadow batches without reconciling them', () => { + const { adapter, context } = adapterFor('target'); + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.committedChangeAdapter = adapter; + const observed = []; + tracker.onShadowComponentCommands = batch => observed.push(batch); + const committed = changeSet(new SetMorphProperty({ + targetId: 'target', property: 'fill', before: 'red', after: 'green' + })); + + const result = tracker.processCommittedChangeSet(committed, context); + + expect(result.commands).to.have.length(1); + expect(observed).deep.equals([tracker.lastShadowCommandBatch]); + expect(tracker.shadowCommandBatches).deep.equals([tracker.lastShadowCommandBatch]); + }); + + it('resolves nested part provenance from component policy metadata', () => { + const metaSymbol = Symbol.for('lively-module-meta'); + const leafPolicy = { + [metaSymbol]: { + moduleId: 'local://components/base.cp.js', + exportedName: 'Leaf', + path: [] + } + }; + const nestedPolicy = { + _parent: leafPolicy, + get parent () { return this._parent; }, + [metaSymbol]: { + moduleId: 'local://components/base.cp.js', + exportedName: 'Base', + path: ['nested part'] + } + }; + const basePolicy = { + asBuildSpec: () => ({ + name: 'base', + submorphs: [{ name: 'nested part', master: nestedPolicy }] + }), + [metaSymbol]: { + moduleId: 'local://components/base.cp.js', + exportedName: 'Base', + path: [] + } + }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.componentModuleId = 'local://components/subject.cp.js'; + tracker.componentDescriptor = { componentName: 'Subject' }; + const resolver = tracker.projectionalComponentDocumentResolver({ + id: 'local://components/subject.cp.js', + recorder: { + Base: { + isComponentDescriptor: true, + stylePolicy: basePolicy, + [metaSymbol]: basePolicy[metaSymbol] + } + } + }); + + const resolved = resolver({ expression: 'Base' }); + + expect(resolved.root.children[0].partComponent.expression).equals('Leaf'); + }); + + it('prepares scalar source projections without applying them', () => { + const source = `const Example = component({ + name: 'example', + submorphs: [{ name: 'target', fill: 'red' }] +});`; + const root = { id: 'runtime-root', name: 'example', owner: null }; + const target = { id: 'target', name: 'target', owner: root }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://shadow-projection/component.cp.js'; + tracker.componentModule = { _source: source }; + tracker.componentDescriptor = { componentName: 'Example' }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://shadow-projection/component.cp.js::Example', + containsMorph: () => true + }); + const committed = changeSet(new SetMorphProperty({ + targetId: 'target', property: 'fill', before: 'red', after: 'green' + })); + + const result = tracker.processCommittedChangeSet(committed, { + resolveMorph: id => id === target.id ? target : id === root.id ? root : null + }); + + expect(result.shadowProjection.supported).to.be.true; + expect(result.shadowProjection.sourceAfter).includes('fill: "green"'); + expect(result.shadowProjection.steps[0].runtimeProjection.changeSet.operations[0]) + .containSubset({ + targetId: target.id, + property: 'fill', + before: 'red', + after: 'green' + }); + expect(result.shadowProjection.steps[0].runtimeProjection.changeSet.origin) + .equals('runtime-projection'); + expect(result.shadowProjection.runtimeChangeSet.operations).to.have.length(1); + expect(result.shadowProjection.inverseRuntimeChangeSet.operations[0]) + .containSubset({ before: 'green', after: 'red' }); + expect(tracker.componentModule._source).equals(source); + expect(tracker.lastShadowCommandBatch.shadowProjection) + .equals(result.shadowProjection); + }); + + it('resolves renamed runtime morphs through their pre-change source names', () => { + const source = `const Example = component({ + name: 'example', + submorphs: [{ name: 'before' }] +});`; + const root = { id: 'runtime-root', name: 'example', owner: null }; + const target = { id: 'target', name: 'after', owner: root }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://shadow-projection/component.cp.js'; + tracker.componentModule = { _source: source }; + tracker.componentDescriptor = { componentName: 'Example' }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://shadow-projection/component.cp.js::Example', + containsMorph: () => true + }); + const committed = changeSet(new SetMorphProperty({ + targetId: 'target', property: 'name', before: 'before', after: 'after' + })); + + const result = tracker.processCommittedChangeSet(committed, { + resolveMorph: id => id === target.id ? target : id === root.id ? root : null + }); + + expect(result.shadowProjection.supported).to.be.true; + expect(result.shadowProjection.sourceAfter).includes('name: "after"'); + expect(tracker.componentModule._source).equals(source); + }); + + it('projects sequential commands against refreshed semantic source metadata', () => { + const source = `const Example = component({ + name: 'example', + submorphs: [{ name: 'before', fill: 'red' }] +});`; + const root = { id: 'runtime-root', name: 'example', owner: null }; + const target = { id: 'target', name: 'after', owner: root }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://shadow-projection/component.cp.js'; + tracker.componentModule = { _source: source }; + tracker.componentDescriptor = { componentName: 'Example' }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://shadow-projection/component.cp.js::Example', + containsMorph: () => true + }); + const committed = changeSet([ + new SetMorphProperty({ + targetId: 'target', property: 'name', before: 'before', after: 'after' + }), + new SetMorphProperty({ + targetId: 'target', property: 'fill', before: 'red', after: 'green' + }) + ]); + + const result = tracker.processCommittedChangeSet(committed, { + resolveMorph: id => id === target.id ? target : id === root.id ? root : null + }); + + expect(result.shadowProjection.supported).to.be.true; + expect(result.shadowProjection.steps).to.have.length(2); + expect(result.shadowProjection.runtimeChangeSet.operations).to.have.length(2); + expect(result.shadowProjection.inverseRuntimeChangeSet.operations.map(operation => + operation.property)).deep.equals(['fill', 'name']); + expect(result.shadowProjection.document.revision).equals(2); + expect(result.shadowProjection.sourceAfter).includes('name: "after"'); + expect(result.shadowProjection.sourceAfter).includes('fill: "green"'); + expect(tracker.componentModule._source).equals(source); + }); + + it('reports unsupported changes without mutating source', async () => { + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = { epiMorph: false }; + tracker.componentModule = {}; + tracker.lastShadowCommandBatch = Object.freeze({ + diagnostics: Object.freeze([Object.freeze({ + kind: 'unsupported-test-change', + message: 'projection required' + })]) + }); + const change = { + target: tracker.trackedComponent, + selector: 'unsupportedProjectionalChange', + args: [], + meta: { reconcileChanges: true } + }; + + let processingError; + let completionError; + try { + await tracker.processChangeInComponent(change); + } catch (error) { + processingError = error; + } + try { + await tracker.onceChangesProcessed(); + } catch (error) { + completionError = error; + } + + expect(processingError).to.be.instanceOf(ProjectionalReconciliationUnsupportedError); + expect(processingError.change).equals(change); + expect(processingError.message).includes('projection required'); + expect(completionError).equals(processingError); + }); + + it('records semantic comparisons after projectional commit completes', async () => { + const source = `const Example = component({ + name: 'example', + fill: 'red' +});`; + const root = { id: 'runtime-root', name: 'example', owner: null }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://shadow-projection/component.cp.js'; + tracker.componentModule = { _source: source }; + tracker.componentDescriptor = { componentName: 'Example' }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://shadow-projection/component.cp.js::Example', + containsMorph: () => true + }); + let finishProjectionalCommit; + tracker._finishPromise = new Promise(resolve => { finishProjectionalCommit = resolve; }); + const committed = changeSet(new SetMorphProperty({ + targetId: root.id, property: 'fill', before: 'red', after: 'green' + })); + + const result = tracker.processCommittedChangeSet(committed, { + resolveMorph: id => id === root.id ? root : null + }); + tracker.componentModule._source = result.shadowProjection.sourceAfter; + finishProjectionalCommit(); + const comparison = await tracker._shadowComparisonPromise; + + expect(comparison.matches).to.be.true; + expect(tracker.lastShadowProjectionComparison).equals(comparison); + expect(tracker.shadowProjectionComparisons).deep.equals([comparison]); + }); + + it('cuts scalar properties over by adopting the direct runtime change atomically', () => { + const source = `const Example = component({ + name: 'example', + fill: 'red' +});`; + let fill = 'green'; + let runtimeWrites = 0; + const root = { id: 'runtime-root', name: 'example', owner: null }; + Object.defineProperty(root, 'fill', { + configurable: true, + enumerable: true, + get: () => fill, + set: value => { runtimeWrites++; fill = value; } + }); + const descriptorCalls = []; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://scalar-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + makeDirty: () => descriptorCalls.push('dirty'), + refreshDependants: () => descriptorCalls.push('refresh') + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://scalar-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const recordedChange = { meta: { reconcileChanges: true } }; + const journalCalls = []; + root.env = { + undoManager: { + undoInProgress: { recorder: { changes: [recordedChange] } }, + discardRecordedChanges: changes => { + journalCalls.push(['discard', changes]); + return changes.length; + }, + addTransaction: (transaction, options) => { + journalCalls.push(['add', transaction, options]); + return transaction; + } + } + }; + const committed = changeSet(new SetMorphProperty({ + targetId: root.id, property: 'fill', before: 'red', after: 'green' + })); + + const result = tracker.processCommittedChangeSet(committed, { + legacyChanges: [recordedChange], + resolveMorph: id => id === root.id ? root : null + }); + + expect(result.shadowProjection.supported).to.be.true; + expect(result.projectionalCommit) + .equals(tracker.lastShadowCommandBatch.projectionalCommit); + expect(result.projectionalCommit.editTransaction) + .to.be.instanceOf(ProjectionalComponentEditTransaction); + expect(tracker.lastShadowCommandBatch.projectionalCommit.state) + .equals(ComponentTransactionState.COMMITTED); + expect(tracker.componentModule._source).includes('fill: "green"'); + expect(tracker._projectionalDocument.revision).equals(1); + expect(fill).equals('green'); + expect(runtimeWrites).equals(0); + expect(descriptorCalls).deep.equals(['dirty', 'refresh']); + expect(journalCalls[0]).deep.equals(['discard', [recordedChange]]); + expect(journalCalls[1][0]).equals('add'); + expect(journalCalls[1][2]).deep.equals({ joinActive: true }); + expect(tracker.ignoreChange(recordedChange)).to.be.true; + expect(tracker.ignoreChange(recordedChange)).to.be.true; + }); + + it('resolves imported component descriptors for nested scalar cutover', () => { + const source = `const Example = component({ + name: 'root', + submorphs: [part(Card, { + name: 'card instance', + submorphs: [{ name: 'label', fill: 'red' }] + })] +});`; + const root = { + id: 'runtime-root', name: 'root', owner: null, + env: { undoManager: new UndoManager() } + }; + const card = { id: 'runtime-card', name: 'card instance', owner: root }; + let fill = 'green'; + let runtimeWrites = 0; + const label = { id: 'runtime-label', name: 'label', owner: card }; + Object.defineProperty(label, 'fill', { + configurable: true, + enumerable: true, + get: () => fill, + set: value => { runtimeWrites++; fill = value; } + }); + root.submorphs = [card]; + card.submorphs = [label]; + label.submorphs = []; + const cardDescriptor = { + isComponentDescriptor: true, + stylePolicy: { + asBuildSpec: () => ({ + name: 'card', + submorphs: [{ name: 'label', submorphs: [{ name: 'icon' }] }] + }) + } + }; + cardDescriptor[Symbol.for('lively-module-meta')] = { + moduleId: 'local://nested-cutover/card.cp.js', + exportedName: 'Card' + }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://nested-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + recorder: { Card: cardDescriptor }, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { _dependants: new Set() }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://nested-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const resolveMorph = id => id === root.id + ? root + : id === card.id ? card : id === label.id ? label : null; + + const result = tracker.processCommittedChangeSet(changeSet(new SetMorphProperty({ + targetId: label.id, + property: 'fill', + before: 'red', + after: 'green' + })), { + legacyChanges: [{}], + resolveMorph + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.componentModule._source).includes('fill: "green"'); + expect(tracker._projectionalDocument.root.children[0].children[0].name) + .equals('label'); + expect(tracker._projectionalDocument.root.children[0].children[0].children[0].name) + .equals('icon'); + expect(fill).equals('green'); + expect(runtimeWrites).equals(0); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(fill).equals('red'); + + root.env.undoManager.redo(); + expect(tracker.componentModule._source).includes('fill: "green"'); + expect(fill).equals('green'); + }); + + it('cuts nested inherited removal over through a resolved part descriptor', () => { + const source = `const Example = component({ + name: 'root', + submorphs: [part(Card, { + name: 'card instance', + submorphs: [{ name: 'label', submorphs: [] }] + })] +});`; + const root = { + id: 'runtime-root', name: 'root', owner: null, + env: { undoManager: new UndoManager() } + }; + const card = { id: 'runtime-card', name: 'card instance', owner: root }; + const label = { id: 'runtime-label', name: 'label', owner: card, submorphs: [] }; + const icon = { id: 'runtime-icon', name: 'icon', owner: null, submorphs: [] }; + root.submorphs = [card]; + card.submorphs = [label]; + const cardDescriptor = { + isComponentDescriptor: true, + stylePolicy: { + asBuildSpec: () => ({ + name: 'card', + submorphs: [{ name: 'label', submorphs: [{ name: 'icon' }] }] + }) + } + }; + cardDescriptor[Symbol.for('lively-module-meta')] = { + moduleId: 'local://nested-removal/card.cp.js', + exportedName: 'Card' + }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://nested-removal/component.cp.js'; + tracker.componentModule = { + _source: source, + recorder: { Card: cardDescriptor }, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { _dependants: new Set() }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://nested-removal/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const resolveMorph = id => id === root.id + ? root + : id === card.id ? card : id === label.id ? label : id === icon.id ? icon : null; + + const result = tracker.processCommittedChangeSet(changeSet(new MoveMorph({ + morphId: icon.id, + from: attachedMorph({ ownerId: label.id, index: 0 }), + to: detachedMorph() + })), { + legacyChanges: [{}], + resolveMorph + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.componentModule._source).includes('without("icon")'); + expect(label.submorphs).deep.equals([]); + expect(icon.owner).equals(null); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(label.submorphs).deep.equals([icon]); + expect(icon.owner).equals(label); + + root.env.undoManager.redo(); + expect(tracker.componentModule._source).includes('without("icon")'); + expect(label.submorphs).deep.equals([]); + expect(icon.owner).equals(null); + }); + + it('cuts an eligible local child rename over with exact undo and redo', () => { + const source = `const Example = component({ + name: 'root', + submorphs: [{ name: 'before' }] +});`; + const root = { + id: 'runtime-root', name: 'root', owner: null, + env: { undoManager: new UndoManager() } + }; + const child = { id: 'runtime-child', name: 'after', owner: root }; + root.submorphs = [child]; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://rename-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + const policySpec = { + name: 'root', + submorphs: [{ name: 'before' }] + }; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { spec: policySpec, _dependants: new Set() }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://rename-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + + const result = tracker.processCommittedChangeSet(changeSet(new SetMorphProperty({ + targetId: child.id, + property: 'name', + before: 'before', + after: 'after' + })), { + legacyChanges: [{}], + resolveMorph: id => id === child.id ? child : id === root.id ? root : null + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.lastShadowCommandBatch).not.haveOwnProperty('renameDiagnostic'); + expect(tracker.componentModule._source).includes('name: "after"'); + expect(child.name).equals('after'); + expect(policySpec.submorphs[0].name).equals('after'); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(child.name).equals('before'); + expect(policySpec.submorphs[0].name).equals('before'); + + root.env.undoManager.redo(); + expect(tracker.componentModule._source).includes('name: "after"'); + expect(child.name).equals('after'); + expect(policySpec.submorphs[0].name).equals('after'); + }); + + it('cuts modeled owner-layout renames over with exact runtime undo and redo', () => { + class LayoutState { + constructor (resizePolicies) { + this.config = { resizePolicies }; + } + + copy () { + return new LayoutState(this.config.resizePolicies.map(([name, policy]) => + [name, { ...policy }])); + } + + handleRenamingOf (before, after) { + this.config.resizePolicies = this.config.resizePolicies.map(([name, policy]) => + [name === before ? after : name, policy]); + } + } + + const source = `const Example = component({ + name: 'root', + layout: new TilingLayout({ + resizePolicies: [['before', { height: 'fixed', width: 'fill' }]] + }), + submorphs: [{ name: 'before' }] +});`; + const root = { + id: 'runtime-root', name: 'root', owner: null, + layout: new LayoutState([['before', { height: 'fixed', width: 'fill' }]]), + env: { undoManager: new UndoManager() } + }; + const child = { id: 'runtime-child', name: 'after', owner: root }; + root.submorphs = [child]; + let refreshCount = 0; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://layout-rename-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { + spec: { + name: 'root', + layout: new LayoutState([ + ['before', { height: 'fixed', width: 'fill' }] + ]), + submorphs: [] + }, + _dependants: new Set() + }, + makeDirty: () => {}, + refreshDependants: () => { refreshCount++; } + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://layout-rename-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const resolveMorph = id => id === child.id ? child : id === root.id ? root : null; + + const result = tracker.processCommittedChangeSet(changeSet(new SetMorphProperty({ + targetId: child.id, + property: 'name', + before: 'before', + after: 'after' + })), { legacyChanges: [{}], resolveMorph }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.lastShadowCommandBatch).not.haveOwnProperty('renameDiagnostic'); + expect(tracker.componentModule._source).includes('["after",'); + expect(root.layout.config.resizePolicies[0][0]).equals('after'); + expect(tracker.componentDescriptor.stylePolicy.spec.layout + .config.resizePolicies[0][0]).equals('after'); + expect(tracker.componentDescriptor.stylePolicy.spec.submorphs) + .deep.equals([{ name: 'after' }]); + expect(refreshCount).equals(0); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(child.name).equals('before'); + expect(root.layout.config.resizePolicies[0][0]).equals('before'); + expect(tracker.componentDescriptor.stylePolicy.spec.layout + .config.resizePolicies[0][0]).equals('before'); + expect(tracker.componentDescriptor.stylePolicy.spec.submorphs).deep.equals([]); + expect(refreshCount).equals(0); + + root.env.undoManager.redo(); + expect(tracker.componentModule._source).includes('["after",'); + expect(child.name).equals('after'); + expect(root.layout.config.resizePolicies[0][0]).equals('after'); + expect(tracker.componentDescriptor.stylePolicy.spec.layout + .config.resizePolicies[0][0]).equals('after'); + expect(tracker.componentDescriptor.stylePolicy.spec.submorphs) + .deep.equals([{ name: 'after' }]); + expect(refreshCount).equals(0); + }); + + it('repairs an unresolved materialized policy path transactionally during rename', () => { + const source = `const Example = component({ + name: 'root', + submorphs: [{ name: 'before' }] +});`; + const root = { + id: 'runtime-root', name: 'root', owner: null, + env: { undoManager: new UndoManager() } + }; + const child = { id: 'runtime-child', name: 'after', owner: root }; + root.submorphs = [child]; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://policy-cache-recovery/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { + spec: { name: 'root', submorphs: [{ name: 'different' }] }, + _dependants: new Set() + }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://policy-cache-recovery/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + + const result = tracker.processCommittedChangeSet(changeSet(new SetMorphProperty({ + targetId: child.id, + property: 'name', + before: 'before', + after: 'after' + })), { + legacyChanges: [{}], + resolveMorph: id => id === child.id ? child : id === root.id ? root : null + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.lastShadowCommandBatch).not.haveOwnProperty('renameDiagnostic'); + expect(tracker.componentModule._source).includes('name: "after"'); + expect(tracker.componentDescriptor.stylePolicy.spec.submorphs.map(({ name }) => name)) + .deep.equals(['different', 'after']); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(child.name).equals('before'); + expect(tracker.componentDescriptor.stylePolicy.spec.submorphs.map(({ name }) => name)) + .deep.equals(['different']); + + root.env.undoManager.redo(); + expect(tracker.componentModule._source).includes('name: "after"'); + expect(child.name).equals('after'); + expect(tracker.componentDescriptor.stylePolicy.spec.submorphs.map(({ name }) => name)) + .deep.equals(['different', 'after']); + }); + + it('repairs an unresolved materialized policy path transactionally during scalar edits', () => { + const source = `const Example = component({ + name: 'root', + submorphs: [{ name: 'child', fill: 'red' }] +});`; + const root = { + id: 'runtime-root', name: 'root', owner: null, + env: { undoManager: new UndoManager() } + }; + const child = { id: 'runtime-child', name: 'child', fill: 'green', owner: root }; + root.submorphs = [child]; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://policy-cache-property-recovery/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { + spec: { name: 'root', submorphs: [{ name: 'different' }] }, + _dependants: new Set() + }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://policy-cache-property-recovery/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + + const result = tracker.processCommittedChangeSet(changeSet(new SetMorphProperty({ + targetId: child.id, + property: 'fill', + before: 'red', + after: 'green' + })), { + legacyChanges: [{}], + resolveMorph: id => id === child.id ? child : id === root.id ? root : null + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.componentModule._source).includes('fill: "green"'); + expect(tracker.componentDescriptor.stylePolicy.spec.submorphs) + .deep.equals([{ name: 'different' }, { name: 'child', fill: 'green' }]); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(child.fill).equals('red'); + expect(tracker.componentDescriptor.stylePolicy.spec.submorphs) + .deep.equals([{ name: 'different' }]); + + root.env.undoManager.redo(); + expect(child.fill).equals('green'); + expect(tracker.componentDescriptor.stylePolicy.spec.submorphs) + .deep.equals([{ name: 'different' }, { name: 'child', fill: 'green' }]); + }); + + it('commits the base rename when a registered dependant is unresolvable', () => { + const source = `const Example = component({ + name: 'root', + submorphs: [{ name: 'before' }] +});`; + const root = { + id: 'runtime-root', name: 'root', owner: null, + env: { undoManager: new UndoManager() } + }; + const child = { id: 'runtime-child', name: 'after', owner: root }; + root.submorphs = [child]; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://rename-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { _dependants: new Set(['derived-policy']) }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://rename-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + + const result = tracker.processCommittedChangeSet(changeSet(new SetMorphProperty({ + targetId: child.id, + property: 'name', + before: 'before', + after: 'after' + })), { + legacyChanges: [{}], + resolveMorph: id => id === child.id ? child : id === root.id ? root : null + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.lastShadowCommandBatch).not.haveOwnProperty('renameDiagnostic'); + expect(tracker.componentModule._source).includes('name: "after"'); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(child.name).equals('before'); + + root.env.undoManager.redo(); + expect(tracker.componentModule._source).includes('name: "after"'); + expect(child.name).equals('after'); + }); + + it('commits a local rename and its derived module propagation with exact undo and redo', () => { + const source = `const Example = component({ + name: 'root', + submorphs: [{ name: 'before' }] +});`; + const derivedSource = `const Derived = component(Example, { + name: 'derived', + submorphs: [{ name: 'before', fill: 'red' }] +});`; + const derivedSourceAfter = derivedSource.replace("name: 'before'", 'name: "after"'); + const root = { + id: 'runtime-root', name: 'root', owner: null, + env: { undoManager: new UndoManager() } + }; + const child = { id: 'runtime-child', name: 'after', owner: root }; + const derivedChild = { id: 'derived-runtime-child', name: 'before' }; + root.submorphs = [child]; + const baseModule = { + id: 'local://rename-cutover/component.cp.js', + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + const derivedModule = { + id: 'local://rename-cutover/derived.cp.js', + _source: derivedSource, + setSource (nextSource) { this._source = nextSource; } + }; + const modules = new Map([ + [baseModule.id, baseModule], + [derivedModule.id, derivedModule] + ]); + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = baseModule.id; + tracker.componentModule = baseModule; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { _dependants: new Set(['derived-policy']) }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.projectionalModuleForId = moduleId => modules.get(moduleId); + tracker.prepareProjectionalDerivedRename = () => Object.freeze({ + supported: true, + components: Object.freeze([]), + modules: Object.freeze([Object.freeze({ + moduleId: derivedModule.id, + sourceBefore: derivedSource, + sourceAfter: derivedSourceAfter + })]), + runtimeRenames: Object.freeze([Object.freeze({ + id: `${derivedModule.id}#Derived:target`, + beforeName: 'before', + afterName: 'after', + target: derivedChild + })]), + diagnostics: Object.freeze([]) + }); + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://rename-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + + const result = tracker.processCommittedChangeSet(changeSet(new SetMorphProperty({ + targetId: child.id, + property: 'name', + before: 'before', + after: 'after' + })), { + legacyChanges: [{}], + resolveMorph: id => id === child.id ? child : id === root.id ? root : null + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.lastShadowCommandBatch).not.haveOwnProperty('renameDiagnostic'); + expect(baseModule._source).includes('name: "after"'); + expect(derivedModule._source).equals(derivedSourceAfter); + expect(derivedChild.name).equals('after'); + + root.env.undoManager.undo(); + expect(baseModule._source).equals(source); + expect(derivedModule._source).equals(derivedSource); + expect(child.name).equals('before'); + expect(derivedChild.name).equals('before'); + + root.env.undoManager.redo(); + expect(baseModule._source).includes('name: "after"'); + expect(derivedModule._source).equals(derivedSourceAfter); + expect(child.name).equals('after'); + expect(derivedChild.name).equals('after'); + }); + + it('allows derived-document renames while retaining owner-layout guards', () => { + const target = { id: 'runtime-child', owner: { layout: null } }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = { id: 'runtime-root' }; + tracker.componentDescriptor = { stylePolicy: { _dependants: new Set() } }; + const batch = parentComponent => ({ + commands: [{ kind: ComponentBridgeCommandKind.RENAME_NODE, nodeId: target.id }], + shadowProjection: { + supported: true, + beforeDocument: { parentComponent } + } + }); + const context = { resolveMorph: () => target }; + + expect(tracker.projectionalRenameDiagnostic(batch({ expression: 'Base' }), context)) + .equals(null); + + target.owner.layout = {}; + expect(tracker.projectionalRenameDiagnostic(batch(null), context).kind) + .equals(ProjectionalRenameDiagnosticKind.OWNER_LAYOUT); + }); + + it('allows a rename under a modeled layout that does not reference the target', () => { + const target = { id: 'runtime-child', owner: { layout: {} } }; + const beforeDocument = new ComponentDocument({ + componentId: 'component', + moduleId: 'local://modeled-layout-rename/component.cp.js', + exportName: 'Example', + root: new ComponentNode({ + id: 'root', + name: 'root', + provenance: localNodeProvenance(), + children: [new ComponentNode({ + id: 'child', + name: 'before', + provenance: localNodeProvenance() + })] + }), + layoutModels: [tilingLayoutModel({ + ownerId: 'root', + expressionTemplate: 'new TilingLayout({ spacing: 2 })', + references: [] + })] + }); + const tracker = Object.create(ComponentChangeTracker.prototype); + const batch = { + commands: [{ kind: ComponentBridgeCommandKind.RENAME_NODE, nodeId: target.id }], + shadowProjection: { + supported: true, + beforeDocument, + steps: [{ + componentCommand: { nodeId: 'child' }, + runtimeProjection: { changeSet: { operations: [] } } + }] + } + }; + + expect(tracker.projectionalRenameDiagnostic(batch, { + resolveMorph: () => target + })).equals(null); + }); + + it('allows a rename under a non-referencing constraint layout', () => { + const target = { id: 'runtime-child', owner: { layout: {} } }; + const beforeDocument = new ComponentDocument({ + componentId: 'component', + moduleId: 'local://constraint-layout-rename/component.cp.js', + exportName: 'Example', + root: new ComponentNode({ + id: 'root', + name: 'root', + provenance: localNodeProvenance(), + properties: { + layout: opaqueProperty( + 'new ConstraintLayout({ submorphSettings: [] })' + ) + }, + children: [new ComponentNode({ + id: 'child', + name: 'before', + provenance: localNodeProvenance() + })] + }) + }); + const tracker = Object.create(ComponentChangeTracker.prototype); + const batch = { + commands: [{ kind: ComponentBridgeCommandKind.RENAME_NODE, nodeId: target.id }], + shadowProjection: { + supported: true, + beforeDocument, + steps: [{ + componentCommand: { nodeId: 'child' }, + runtimeProjection: { changeSet: { operations: [] } } + }] + } + }; + + expect(tracker.projectionalRenameDiagnostic(batch, { + resolveMorph: () => target + })).equals(null); + }); + + it('cuts a direct derived scalar edit over across source, runtime, and policy cache', () => { + const source = `const Derived = component(Base, { + name: 'derived', + fill: 'red' +});`; + let fill = 'green'; + let runtimeWrites = 0; + const root = { + id: 'runtime-root', name: 'derived', owner: null, + env: { undoManager: new UndoManager() } + }; + Object.defineProperty(root, 'fill', { + configurable: true, + enumerable: true, + get: () => fill, + set: value => { runtimeWrites++; fill = value; } + }); + root.submorphs = []; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://derived-scalar-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Derived', + stylePolicy: { + parent: { isPolicy: true }, + spec: { name: 'derived', fill: 'red' }, + _dependants: new Set() + }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://derived-scalar-cutover/component.cp.js::Derived', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + + const result = tracker.processCommittedChangeSet(changeSet(new SetMorphProperty({ + targetId: root.id, + property: 'fill', + before: 'red', + after: 'green' + })), { + legacyChanges: [{}], + resolveMorph: id => id === root.id ? root : null + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.componentModule._source).includes('fill: "green"'); + expect(fill).equals('green'); + expect(runtimeWrites).equals(0); + expect(tracker.componentDescriptor.stylePolicy.spec.fill).equals('green'); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(fill).equals('red'); + expect(tracker.componentDescriptor.stylePolicy.spec.fill).equals('red'); + + root.env.undoManager.redo(); + expect(tracker.componentModule._source).includes('fill: "green"'); + expect(fill).equals('green'); + expect(tracker.componentDescriptor.stylePolicy.spec.fill).equals('green'); + }); + + it('cuts a direct derived master edit over across source, runtime, and policy cache', () => { + class SerializableMaster { + getConfigAsExpression () { + return { + __expr__: 'HoverMaster', + bindings: { 'local://masters.js': ['HoverMaster'] } + }; + } + } + class SerializableBaseMaster { + getConfigAsExpression () { + return { __expr__: 'BaseMaster', bindings: {} }; + } + } + const previousMaster = new SerializableBaseMaster(); + const nextMaster = new SerializableMaster(); + const source = `const Derived = component(Base, { + name: 'derived', + master: BaseMaster +});`; + const root = { + id: 'runtime-root', name: 'derived', master: nextMaster, owner: null, + submorphs: [], env: { undoManager: new UndoManager() } + }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://derived-master-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Derived', + stylePolicy: { + parent: { + isPolicy: true, + asBuildSpec: () => ({ name: 'base', submorphs: [] }) + }, + spec: { name: 'derived', master: previousMaster }, + _dependants: new Set() + }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://derived-master-cutover/component.cp.js::Derived', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + + const result = tracker.processCommittedChangeSet(changeSet(new SetMorphProperty({ + targetId: root.id, + property: 'master', + before: previousMaster, + after: nextMaster + })), { + legacyChanges: [{}], + resolveMorph: id => id === root.id ? root : null + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.componentModule._source) + .includes('import { HoverMaster } from "local://masters.js";'); + expect(tracker.componentModule._source).includes('master: HoverMaster'); + expect(root.master, 'runtime after commit').equals(nextMaster); + expect(tracker.componentDescriptor.stylePolicy.spec.master, 'policy after commit') + .equals(nextMaster); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(root.master, 'runtime after undo').equals(previousMaster); + expect(tracker.componentDescriptor.stylePolicy.spec.master, 'policy after undo') + .equals(previousMaster); + + root.env.undoManager.redo(); + expect(tracker.componentModule._source).includes('master: HoverMaster'); + expect(root.master, 'runtime after redo').equals(nextMaster); + expect(tracker.componentDescriptor.stylePolicy.spec.master, 'policy after redo') + .equals(nextMaster); + }); + + it('cuts a direct derived layout replacement over with refreshed layout semantics', () => { + class SerializableLayout { + __serialize__ () { + return { + __expr__: `new TilingLayout({ + resizePolicies: [['child', { height: 'fixed', width: 'fill' }]] +})`, + bindings: { 'lively.morphic': ['TilingLayout'] } + }; + } + } + const nextLayout = new SerializableLayout(); + const source = `const Derived = component(Base, { + name: 'derived', + layout: null +});`; + const root = { + id: 'runtime-root', name: 'derived', layout: nextLayout, owner: null, + env: { undoManager: new UndoManager() } + }; + const child = { + id: 'runtime-child', name: 'child', owner: root, submorphs: [] + }; + root.submorphs = [child]; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://derived-layout-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Derived', + stylePolicy: { + parent: { + isPolicy: true, + asBuildSpec: () => ({ + name: 'base', + submorphs: [{ name: 'child' }] + }) + }, + spec: { name: 'derived', layout: null }, + _dependants: new Set() + }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://derived-layout-cutover/component.cp.js::Derived', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + + const result = tracker.processCommittedChangeSet(changeSet(new SetMorphProperty({ + targetId: root.id, + property: 'layout', + before: null, + after: nextLayout + })), { + legacyChanges: [{}], + resolveMorph: id => id === root.id ? root : id === child.id ? child : null + }); + + expect(result.projectionalCommit, + JSON.stringify(result.shadowProjection?.diagnostics || [])).not.equals(null); + expect(tracker.componentModule._source) + .includes('import { TilingLayout } from "lively.morphic";'); + expect(tracker.componentModule._source).includes("resizePolicies: [['child'"); + expect(tracker._projectionalDocument.layoutModels).length(1); + expect(root.layout).equals(nextLayout); + expect(tracker.componentDescriptor.stylePolicy.spec.layout).equals(nextLayout); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(root.layout).equals(null); + expect(tracker.componentDescriptor.stylePolicy.spec.layout).equals(null); + + root.env.undoManager.redo(); + expect(tracker.componentModule._source).includes("resizePolicies: [['child'"); + expect(root.layout).equals(nextLayout); + expect(tracker.componentDescriptor.stylePolicy.spec.layout).equals(nextLayout); + }); + + it('cuts a directly added derived child rename over using the actual parent policy', () => { + const source = `const Derived = component(Base, { + name: 'derived', + submorphs: [add({ name: 'before', fill: 'red' })] +});`; + const root = { + id: 'runtime-root', name: 'derived', owner: null, + env: { undoManager: new UndoManager() } + }; + const child = { + id: 'runtime-child', name: 'after', fill: 'red', owner: root, + submorphs: [] + }; + root.submorphs = [child]; + const policyChild = { name: 'before', fill: 'red' }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://derived-rename-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Derived', + stylePolicy: { + parent: { + isPolicy: true, + asBuildSpec: () => ({ name: 'base', submorphs: [] }) + }, + spec: { + name: 'derived', + submorphs: [{ COMMAND: 'add', props: policyChild }] + }, + _dependants: new Set() + }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://derived-rename-cutover/component.cp.js::Derived', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const resolveMorph = id => id === root.id ? root : id === child.id ? child : null; + + const result = tracker.processCommittedChangeSet(changeSet(new SetMorphProperty({ + targetId: child.id, + property: 'name', + before: 'before', + after: 'after' + })), { + legacyChanges: [{}], + resolveMorph + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.lastShadowCommandBatch).not.haveOwnProperty('renameDiagnostic'); + expect(tracker.componentModule._source).includes('name: "after"'); + expect(child.name).equals('after'); + expect(policyChild.name).equals('after'); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(child.name).equals('before'); + expect(policyChild.name).equals('before'); + + root.env.undoManager.redo(); + expect(tracker.componentModule._source).includes('name: "after"'); + expect(child.name).equals('after'); + expect(policyChild.name).equals('after'); + }); + + it('cuts an inherited derived child rename over through replace selectors', () => { + const source = `const Derived = component(Base, { + name: 'derived', + submorphs: [{ name: 'before', fill: 'red' }] +});`; + const root = { + id: 'runtime-root', name: 'derived', owner: null, + env: { undoManager: new UndoManager() } + }; + const child = { + id: 'runtime-child', name: 'after', fill: 'red', owner: root, + submorphs: [] + }; + root.submorphs = [child]; + const policyChild = { name: 'before', fill: 'red' }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://derived-inherited-rename/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Derived', + stylePolicy: { + parent: { + isPolicy: true, + asBuildSpec: () => ({ + name: 'base', + submorphs: [{ name: 'before' }] + }) + }, + spec: { + name: 'derived', + submorphs: [policyChild] + }, + _dependants: new Set() + }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://derived-inherited-rename/component.cp.js::Derived', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const resolveMorph = id => id === root.id ? root : id === child.id ? child : null; + + const result = tracker.processCommittedChangeSet(changeSet(new SetMorphProperty({ + targetId: child.id, + property: 'name', + before: 'before', + after: 'after' + })), { + legacyChanges: [{}], + resolveMorph + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.lastShadowCommandBatch).not.haveOwnProperty('renameDiagnostic'); + expect(tracker.componentModule._source) + .includes('import { replace } from "lively.morphic/components/core.js";'); + expect(tracker.componentModule._source) + .includes(`replace("before", { name: "after", fill: 'red' })`); + expect(child.name).equals('after'); + expect(policyChild.name).equals('after'); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(child.name).equals('before'); + expect(policyChild.name).equals('before'); + + root.env.undoManager.redo(); + expect(tracker.componentModule._source) + .includes(`replace("before", { name: "after", fill: 'red' })`); + expect(child.name).equals('after'); + expect(policyChild.name).equals('after'); + }); + + it('cuts a direct derived introduction over as an explicit add', () => { + const source = `const Derived = component(Base, { + name: 'derived', + submorphs: [] +});`; + const root = { + id: 'runtime-root', name: 'derived', owner: null, + env: { undoManager: new UndoManager() } + }; + const introduced = { + id: 'runtime-introduced', name: 'introduced', fill: 'green', owner: root, + spec: () => ({ name: 'introduced', fill: 'green', submorphs: [] }) + }; + root.submorphs = [introduced]; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://derived-introduction-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Derived', + stylePolicy: { + parent: { + isPolicy: true, + asBuildSpec: () => ({ name: 'base', submorphs: [] }) + }, + _dependants: new Set() + }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://derived-introduction-cutover/component.cp.js::Derived', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const resolveMorph = id => id === root.id + ? root + : id === introduced.id ? introduced : null; + + const result = tracker.processCommittedChangeSet(changeSet(new MoveMorph({ + morphId: introduced.id, + from: detachedMorph(), + to: attachedMorph({ ownerId: root.id, index: 0 }) + })), { + legacyChanges: [{}], + resolveMorph + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.componentModule._source) + .includes('add({ name: "introduced", fill: "green" })'); + expect(root.submorphs).deep.equals([introduced]); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(root.submorphs).deep.equals([]); + + root.env.undoManager.redo(); + expect(tracker.componentModule._source) + .includes('add({ name: "introduced", fill: "green" })'); + expect(root.submorphs).deep.equals([introduced]); + }); + + it('cuts removal of a directly added derived child over exactly', () => { + const source = `const Derived = component(Base, { + name: 'derived', + submorphs: [add({ name: 'removed', fill: 'red' })] +});`; + const root = { + id: 'runtime-root', name: 'derived', owner: null, + submorphs: [], env: { undoManager: new UndoManager() } + }; + const removed = { + id: 'runtime-removed', name: 'removed', fill: 'red', owner: null, + submorphs: [] + }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://derived-removal-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Derived', + stylePolicy: { + parent: { + isPolicy: true, + asBuildSpec: () => ({ name: 'base', submorphs: [] }) + }, + _dependants: new Set() + }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://derived-removal-cutover/component.cp.js::Derived', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const resolveMorph = id => id === root.id ? root : id === removed.id ? removed : null; + + const result = tracker.processCommittedChangeSet(changeSet(new MoveMorph({ + morphId: removed.id, + from: attachedMorph({ ownerId: root.id, index: 0 }), + to: detachedMorph() + })), { + legacyChanges: [{}], + resolveMorph + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.componentModule._source).not.includes('name: \'removed\''); + expect(root.submorphs).deep.equals([]); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(root.submorphs).deep.equals([removed]); + expect(removed.owner).equals(root); + + root.env.undoManager.redo(); + expect(tracker.componentModule._source).not.includes('name: \'removed\''); + expect(root.submorphs).deep.equals([]); + expect(removed.owner).equals(null); + }); + + it('cuts an eligible final local child removal over with exact undo and redo', () => { + const source = `const Example = component({ + name: 'root', + submorphs: [ + { name: 'first' }, + { name: 'removed' } + ] +});`; + const root = { + id: 'runtime-root', name: 'root', owner: null, + env: { undoManager: new UndoManager() } + }; + const first = { id: 'runtime-first', name: 'first', owner: root }; + const removed = { id: 'runtime-removed', name: 'removed', owner: null }; + const derivedRoot = { id: 'derived-runtime-root', name: 'derived', owner: null }; + const derivedRemoved = { + id: 'derived-runtime-removed', name: 'removed', owner: derivedRoot + }; + root.submorphs = [first]; + derivedRoot.submorphs = [derivedRemoved]; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://removal-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { _dependants: new Set(['derived-policy']) }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://removal-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const resolveMorph = id => id === root.id + ? root + : id === first.id ? first : id === removed.id ? removed : null; + const resolveDerivedMorph = id => id === derivedRoot.id + ? derivedRoot + : id === derivedRemoved.id ? derivedRemoved : resolveMorph(id); + const derivedChangeSet = new MorphicChangeSet({ + id: 'derived-removal-runtime', + origin: 'runtime-projection', + undoable: false, + operations: [new MoveMorph({ + morphId: derivedRemoved.id, + from: attachedMorph({ ownerId: derivedRoot.id, index: 0 }), + to: detachedMorph() + })] + }); + tracker.prepareProjectionalDerivedStructure = () => Object.freeze({ + supported: true, + components: Object.freeze([]), + modules: Object.freeze([]), + runtimeRenames: Object.freeze([]), + runtimeStructuralProjection: Object.freeze({ + changeSet: derivedChangeSet, + inverseChangeSet: derivedChangeSet.invert({ id: 'derived-removal-runtime:inverse' }), + runtimeContext: tracker.runtimeProjectionContext({ + resolveMorph: resolveDerivedMorph + }) + }), + diagnostics: Object.freeze([]) + }); + + const result = tracker.processCommittedChangeSet(changeSet(new MoveMorph({ + morphId: removed.id, + from: attachedMorph({ ownerId: root.id, index: 1 }), + to: detachedMorph() + })), { + legacyChanges: [{}], + resolveMorph + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.componentModule._source).includes("{ name: 'first' }"); + expect(tracker.componentModule._source).not.includes("{ name: 'removed' }"); + expect(root.submorphs).deep.equals([first]); + expect(removed.owner).equals(null); + expect(derivedRoot.submorphs).deep.equals([]); + expect(derivedRemoved.owner).equals(null); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(root.submorphs).deep.equals([first, removed]); + expect(removed.owner).equals(root); + expect(derivedRoot.submorphs).deep.equals([derivedRemoved]); + expect(derivedRemoved.owner).equals(derivedRoot); + + root.env.undoManager.redo(); + expect(tracker.componentModule._source).not.includes("{ name: 'removed' }"); + expect(root.submorphs).deep.equals([first]); + expect(removed.owner).equals(null); + expect(derivedRoot.submorphs).deep.equals([]); + expect(derivedRemoved.owner).equals(null); + }); + + it('cuts a modeled tiling-layout removal over with exact runtime undo and redo', () => { + const source = `const Example = component({ + name: 'root', + layout: new TilingLayout({ + resizePolicies: [ + ['first', { height: 'fixed', width: 'fill' }], + ['removed', { height: 'fill', width: 'fixed' }] + ] + }), + submorphs: [{ name: 'first' }, { name: 'removed' }] +});`; + const beforeLayout = new RuntimeTilingLayoutState([ + ['first', { height: 'fixed', width: 'fill' }], + ['removed', { height: 'fill', width: 'fixed' }] + ]); + const root = { + id: 'runtime-root', name: 'root', owner: null, + layout: new RuntimeTilingLayoutState([ + ['first', { height: 'fixed', width: 'fill' }] + ]), + env: { undoManager: new UndoManager() } + }; + const first = { id: 'runtime-first', name: 'first', owner: root }; + const removed = { id: 'runtime-removed', name: 'removed', owner: null }; + root.submorphs = [first]; + installLayoutAwareMorphOperations(root); + installRemovableMorphOperation(removed); + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://layout-removal-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { + _dependants: new Set(), + spec: { layout: beforeLayout } + }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://layout-removal-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const targets = new Map([root, first, removed].map(morph => [morph.id, morph])); + + const result = tracker.processCommittedChangeSet(changeSet(new MoveMorph({ + morphId: removed.id, + from: attachedMorph({ ownerId: root.id, index: 1 }), + to: detachedMorph() + })), { + legacyChanges: [{}], + resolveMorph: id => targets.get(id) + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.componentModule._source).not.includes("['removed',"); + expect(tracker.componentModule._source).not.includes("name: 'removed'"); + expect(root.layout.config.resizePolicies.map(([name]) => name)).deep.equals(['first']); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(root.submorphs).deep.equals([first, removed]); + expect(root.layout.config.resizePolicies.map(([name]) => name)) + .deep.equals(['first', 'removed']); + + root.env.undoManager.redo(); + expect(root.submorphs).deep.equals([first]); + expect(removed.owner).equals(null); + expect(root.layout.config.resizePolicies.map(([name]) => name)).deep.equals(['first']); + }); + + it('cuts over removal before a surviving source-path sibling', () => { + const source = `const Example = component({ + name: 'root', + submorphs: [ + { name: 'removed' }, + { name: 'last' } + ] +});`; + const root = { id: 'runtime-root', name: 'root', owner: null }; + const removed = { id: 'runtime-removed', name: 'removed', owner: null }; + const last = { id: 'runtime-last', name: 'last', owner: root }; + root.submorphs = [last]; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://removal-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { _dependants: new Set() } + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://removal-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const resolveMorph = id => id === root.id + ? root + : id === removed.id ? removed : id === last.id ? last : null; + + const result = tracker.processCommittedChangeSet(changeSet(new MoveMorph({ + morphId: removed.id, + from: attachedMorph({ ownerId: root.id, index: 0 }), + to: detachedMorph() + })), { + legacyChanges: [{}], + resolveMorph + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.componentModule._source).not.includes("name: 'removed'"); + expect(tracker.componentModule._source).includes("name: 'last'"); + expect(tracker._projectionalDocument.root.children[0].id) + .equals(`${tracker.committedChangeAdapter.componentId}:node:1`); + }); + + it('restores a directly removed child when structural source commit fails', () => { + const source = `const Example = component({ + name: 'root', + submorphs: [{ name: 'removed' }] +});`; + const root = { id: 'runtime-root', name: 'root', owner: null, submorphs: [] }; + const removed = { id: 'runtime-removed', name: 'removed', owner: null }; + const sourceError = new Error('structural source cutover failed'); + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://removal-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { + if (nextSource !== source) throw sourceError; + this._source = nextSource; + } + }; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { _dependants: new Set() }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://removal-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const resolveMorph = id => id === root.id + ? root + : id === removed.id ? removed : null; + + let error; + try { + tracker.processCommittedChangeSet(changeSet(new MoveMorph({ + morphId: removed.id, + from: attachedMorph({ ownerId: root.id, index: 0 }), + to: detachedMorph() + })), { + legacyChanges: [{}], + resolveMorph + }); + } catch (caughtError) { + error = caughtError; + } + + expect(error).equals(sourceError); + expect(tracker.componentModule._source).equals(source); + expect(root.submorphs).deep.equals([removed]); + expect(removed.owner).equals(root); + }); + + it('cuts inherited removal over as suppression with exact undo and redo', () => { + const source = `const Example = component(Base, { name: 'derived' });`; + const root = { + id: 'runtime-root', name: 'derived', owner: null, submorphs: [], + env: { undoManager: new UndoManager() } + }; + const inherited = { + id: 'runtime-inherited', name: 'inherited child', owner: null, submorphs: [] + }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://inherited-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { parent: {}, _dependants: new Set() }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://inherited-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const targets = new Map([root, inherited].map(morph => [morph.id, morph])); + + const result = tracker.processCommittedChangeSet(changeSet(new MoveMorph({ + morphId: inherited.id, + from: attachedMorph({ ownerId: root.id, index: 0 }), + to: detachedMorph() + })), { + legacyChanges: [{}], + resolveMorph: id => targets.get(id) + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.componentModule._source).includes('without("inherited child")'); + expect(root.submorphs).deep.equals([]); + expect(inherited.owner).equals(null); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(root.submorphs).deep.equals([inherited]); + expect(inherited.owner).equals(root); + + root.env.undoManager.redo(); + expect(tracker.componentModule._source).includes('without("inherited child")'); + expect(root.submorphs).deep.equals([]); + expect(inherited.owner).equals(null); + }); + + it('cuts inherited reintroduction over as restoration with exact undo and redo', () => { + const source = `import { without } from 'lively.morphic/components/core.js'; + +const Example = component(Base, { + name: 'derived', + submorphs: [without('inherited child')] +});`; + const root = { + id: 'runtime-root', name: 'derived', owner: null, + env: { undoManager: new UndoManager() } + }; + const inherited = { + id: 'runtime-inherited', name: 'inherited child', owner: root, submorphs: [] + }; + root.submorphs = [inherited]; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://inherited-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { parent: {}, _dependants: new Set() }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://inherited-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const targets = new Map([root, inherited].map(morph => [morph.id, morph])); + + const result = tracker.processCommittedChangeSet(changeSet(new MoveMorph({ + morphId: inherited.id, + from: detachedMorph(), + to: attachedMorph({ ownerId: root.id, index: 0 }) + })), { + legacyChanges: [{}], + resolveMorph: id => targets.get(id) + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.componentModule._source).not.includes("without('inherited child')"); + expect(root.submorphs).deep.equals([inherited]); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(root.submorphs).deep.equals([]); + expect(inherited.owner).equals(null); + + root.env.undoManager.redo(); + expect(tracker.componentModule._source).not.includes("without('inherited child')"); + expect(root.submorphs).deep.equals([inherited]); + expect(inherited.owner).equals(root); + }); + + it('restores an inherited node when suppression source commit fails', () => { + const source = `const Example = component(Base, { name: 'derived' });`; + const root = { id: 'runtime-root', name: 'derived', owner: null, submorphs: [] }; + const inherited = { + id: 'runtime-inherited', name: 'inherited child', owner: null, submorphs: [] + }; + const sourceError = new Error('inherited suppression source cutover failed'); + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://inherited-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { + if (nextSource !== source) throw sourceError; + this._source = nextSource; + } + }; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { parent: {}, _dependants: new Set() }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://inherited-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const targets = new Map([root, inherited].map(morph => [morph.id, morph])); + + let error; + try { + tracker.processCommittedChangeSet(changeSet(new MoveMorph({ + morphId: inherited.id, + from: attachedMorph({ ownerId: root.id, index: 0 }), + to: detachedMorph() + })), { + legacyChanges: [{}], + resolveMorph: id => targets.get(id) + }); + } catch (caughtError) { + error = caughtError; + } + + expect(error).equals(sourceError); + expect(tracker.componentModule._source).equals(source); + expect(root.submorphs).deep.equals([inherited]); + expect(inherited.owner).equals(root); + }); + + it('cuts an eligible local sibling reorder over with exact undo and redo', () => { + const source = `const Example = component({ + name: 'root', + submorphs: [ + { name: 'first' }, + { name: 'second' }, + { name: 'third' } + ] +});`; + const root = { + id: 'runtime-root', name: 'root', owner: null, + env: { undoManager: new UndoManager() } + }; + const first = { id: 'runtime-first', name: 'first', owner: root }; + const second = { id: 'runtime-second', name: 'second', owner: root }; + const third = { id: 'runtime-third', name: 'third', owner: root }; + root.submorphs = [third, first, second]; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://reorder-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { _dependants: new Set() }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://reorder-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const targets = new Map([root, first, second, third].map(morph => [morph.id, morph])); + + const result = tracker.processCommittedChangeSet(changeSet(new MoveMorph({ + morphId: third.id, + from: attachedMorph({ ownerId: root.id, index: 2 }), + to: attachedMorph({ ownerId: root.id, index: 0 }) + })), { + legacyChanges: [{}], + resolveMorph: id => targets.get(id) + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.componentModule._source.indexOf("name: 'third'")) + .to.be.lessThan(tracker.componentModule._source.indexOf("name: 'first'")); + expect(root.submorphs).deep.equals([third, first, second]); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(root.submorphs).deep.equals([first, second, third]); + + root.env.undoManager.redo(); + expect(root.submorphs).deep.equals([third, first, second]); + }); + + it('cuts an eligible local reparent over with exact undo and redo', () => { + const source = `const Example = component({ + name: 'root', + submorphs: [{ + name: 'source', + submorphs: [{ name: 'moved' }] + }, { + name: 'destination' + }] +});`; + const root = { + id: 'runtime-root', name: 'root', owner: null, + env: { undoManager: new UndoManager() } + }; + const sourceParent = { + id: 'runtime-source', name: 'source', owner: root, submorphs: [] + }; + const destination = { + id: 'runtime-destination', name: 'destination', owner: root, submorphs: [] + }; + const moved = { id: 'runtime-moved', name: 'moved', owner: destination }; + destination.submorphs = [moved]; + root.submorphs = [sourceParent, destination]; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://reparent-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { _dependants: new Set() }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://reparent-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const targets = new Map([root, sourceParent, destination, moved] + .map(morph => [morph.id, morph])); + + const result = tracker.processCommittedChangeSet(changeSet(new MoveMorph({ + morphId: moved.id, + from: attachedMorph({ ownerId: sourceParent.id, index: 0 }), + to: attachedMorph({ ownerId: destination.id, index: 0 }) + })), { + legacyChanges: [{}], + resolveMorph: id => targets.get(id) + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker._projectionalDocument.root.children[0].children).deep.equals([]); + expect(tracker._projectionalDocument.root.children[1].children.map(({ name }) => name)) + .deep.equals(['moved']); + expect(sourceParent.submorphs).deep.equals([]); + expect(destination.submorphs).deep.equals([moved]); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(sourceParent.submorphs).deep.equals([moved]); + expect(destination.submorphs).deep.equals([]); + expect(moved.owner).equals(sourceParent); + + root.env.undoManager.redo(); + expect(sourceParent.submorphs).deep.equals([]); + expect(destination.submorphs).deep.equals([moved]); + expect(moved.owner).equals(destination); + }); + + it('cuts a reparent out of a modeled tiling layout over with exact undo and redo', () => { + const source = `const Example = component({ + name: 'root', + submorphs: [{ + name: 'source', + layout: new TilingLayout({ + resizePolicies: [ + ['keeper', { height: 'fixed', width: 'fill' }], + ['moved', { height: 'fill', width: 'fixed' }] + ] + }), + submorphs: [{ name: 'keeper' }, { name: 'moved' }] + }, { + name: 'destination' + }] +});`; + const root = { + id: 'runtime-root', name: 'root', owner: null, + env: { undoManager: new UndoManager() } + }; + const beforeLayout = new RuntimeTilingLayoutState([ + ['keeper', { height: 'fixed', width: 'fill' }], + ['moved', { height: 'fill', width: 'fixed' }] + ]); + const sourceParent = { + id: 'runtime-source', name: 'source', owner: root, + layout: new RuntimeTilingLayoutState([ + ['keeper', { height: 'fixed', width: 'fill' }] + ]) + }; + const destination = { + id: 'runtime-destination', name: 'destination', owner: root, layout: null + }; + const keeper = { id: 'runtime-keeper', name: 'keeper', owner: sourceParent }; + const moved = { id: 'runtime-moved', name: 'moved', owner: destination }; + root.submorphs = [sourceParent, destination]; + sourceParent.submorphs = [keeper]; + destination.submorphs = [moved]; + installLayoutAwareMorphOperations(sourceParent); + installLayoutAwareMorphOperations(destination); + installRemovableMorphOperation(moved); + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://layout-reparent-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { + _dependants: new Set(), + getSubSpecAt: path => path.join('/') === 'source' + ? { layout: beforeLayout } + : null + }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://layout-reparent-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const targets = new Map([root, sourceParent, destination, keeper, moved] + .map(morph => [morph.id, morph])); + + const result = tracker.processCommittedChangeSet(changeSet(new MoveMorph({ + morphId: moved.id, + from: attachedMorph({ ownerId: sourceParent.id, index: 1 }), + to: attachedMorph({ ownerId: destination.id, index: 0 }) + })), { + legacyChanges: [{}], + resolveMorph: id => targets.get(id) + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.componentModule._source).not.includes("['moved',"); + expect(sourceParent.layout.config.resizePolicies.map(([name]) => name)) + .deep.equals(['keeper']); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(sourceParent.submorphs).deep.equals([keeper, moved]); + expect(destination.submorphs).deep.equals([]); + expect(sourceParent.layout.config.resizePolicies.map(([name]) => name)) + .deep.equals(['keeper', 'moved']); + + root.env.undoManager.redo(); + expect(sourceParent.submorphs).deep.equals([keeper]); + expect(destination.submorphs).deep.equals([moved]); + expect(moved.owner).equals(destination); + expect(sourceParent.layout.config.resizePolicies.map(([name]) => name)) + .deep.equals(['keeper']); + }); + + it('cuts over reparenting through a runtime-only owner layout', () => { + const root = { id: 'runtime-root', name: 'root', owner: null }; + const sourceParent = { + id: 'runtime-source', name: 'source', owner: root, submorphs: [] + }; + const destination = { + id: 'runtime-destination', name: 'destination', owner: root, + submorphs: [], layout: {} + }; + const moved = { id: 'runtime-moved', name: 'moved', owner: destination }; + destination.submorphs = [moved]; + root.submorphs = [sourceParent, destination]; + const source = `const Example = component({ + name: 'root', + submorphs: [{ + name: 'source', + submorphs: [{ name: 'moved' }] + }, { name: 'destination' }] +});`; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://reparent-layout/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', stylePolicy: { _dependants: new Set() }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://reparent-layout/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const targets = new Map([root, sourceParent, destination, moved] + .map(morph => [morph.id, morph])); + + const result = tracker.processCommittedChangeSet(changeSet(new MoveMorph({ + morphId: moved.id, + from: attachedMorph({ ownerId: sourceParent.id, index: 0 }), + to: attachedMorph({ ownerId: destination.id, index: 0 }) + })), { + legacyChanges: [{}], + resolveMorph: id => targets.get(id) + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.componentModule._source).not.equals(source); + expect(tracker.componentModule._source.indexOf("name: 'moved'")) + .above(tracker.componentModule._source.indexOf("name: 'destination'")); + }); + + it('restores the old owner when reparent source commit fails', () => { + const source = `const Example = component({ + name: 'root', + submorphs: [{ + name: 'source', + submorphs: [{ name: 'moved' }] + }, { name: 'destination' }] +});`; + const root = { id: 'runtime-root', name: 'root', owner: null }; + const sourceParent = { + id: 'runtime-source', name: 'source', owner: root, submorphs: [] + }; + const destination = { + id: 'runtime-destination', name: 'destination', owner: root, submorphs: [] + }; + const moved = { id: 'runtime-moved', name: 'moved', owner: destination }; + destination.submorphs = [moved]; + root.submorphs = [sourceParent, destination]; + const sourceError = new Error('reparent source cutover failed'); + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://reparent-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { + if (nextSource !== source) throw sourceError; + this._source = nextSource; + } + }; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { _dependants: new Set() }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://reparent-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const targets = new Map([root, sourceParent, destination, moved] + .map(morph => [morph.id, morph])); + + let error; + try { + tracker.processCommittedChangeSet(changeSet(new MoveMorph({ + morphId: moved.id, + from: attachedMorph({ ownerId: sourceParent.id, index: 0 }), + to: attachedMorph({ ownerId: destination.id, index: 0 }) + })), { + legacyChanges: [{}], + resolveMorph: id => targets.get(id) + }); + } catch (caughtError) { + error = caughtError; + } + + expect(error).equals(sourceError); + expect(tracker.componentModule._source).equals(source); + expect(sourceParent.submorphs).deep.equals([moved]); + expect(destination.submorphs).deep.equals([]); + expect(moved.owner).equals(sourceParent); + }); + + it('restores the old sibling order when reorder source commit fails', () => { + const source = `const Example = component({ + name: 'root', + submorphs: [{ name: 'first' }, { name: 'second' }] +});`; + const root = { id: 'runtime-root', name: 'root', owner: null }; + const first = { id: 'runtime-first', name: 'first', owner: root }; + const second = { id: 'runtime-second', name: 'second', owner: root }; + root.submorphs = [second, first]; + const sourceError = new Error('reorder source cutover failed'); + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://reorder-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { + if (nextSource !== source) throw sourceError; + this._source = nextSource; + } + }; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { _dependants: new Set() }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://reorder-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const targets = new Map([root, first, second].map(morph => [morph.id, morph])); + + let error; + try { + tracker.processCommittedChangeSet(changeSet(new MoveMorph({ + morphId: second.id, + from: attachedMorph({ ownerId: root.id, index: 1 }), + to: attachedMorph({ ownerId: root.id, index: 0 }) + })), { + legacyChanges: [{}], + resolveMorph: id => targets.get(id) + }); + } catch (caughtError) { + error = caughtError; + } + + expect(error).equals(sourceError); + expect(tracker.componentModule._source).equals(source); + expect(root.submorphs).deep.equals([first, second]); + }); + + it('cuts an eligible appended plain morph introduction over with exact undo and redo', () => { + const source = `const Example = component({ + name: 'root', + submorphs: [{ name: 'first' }] +});`; + const root = { + id: 'runtime-root', name: 'root', owner: null, + env: { undoManager: new UndoManager() } + }; + const first = { id: 'runtime-first', name: 'first', owner: root }; + const introduced = { + id: 'runtime-introduced', + name: 'introduced', + fill: 'green', + owner: root, + spec: () => ({ name: 'introduced', fill: 'green', submorphs: [] }) + }; + root.submorphs = [first, introduced]; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://introduction-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { _dependants: new Set() }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://introduction-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const resolveMorph = id => id === root.id + ? root + : id === first.id ? first : id === introduced.id ? introduced : null; + + const result = tracker.processCommittedChangeSet(changeSet(new MoveMorph({ + morphId: introduced.id, + from: detachedMorph(), + to: attachedMorph({ ownerId: root.id, index: 1 }) + })), { + legacyChanges: [{}], + resolveMorph + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.componentModule._source) + .includes('{ name: "introduced", fill: "green" }'); + expect(root.submorphs).deep.equals([first, introduced]); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(root.submorphs).deep.equals([first]); + expect(introduced.owner).equals(null); + + root.env.undoManager.redo(); + expect(tracker.componentModule._source) + .includes('{ name: "introduced", fill: "green" }'); + expect(root.submorphs).deep.equals([first, introduced]); + expect(introduced.owner).equals(root); + }); + + it('cuts a source-located typed morph introduction over with its import', () => { + class TypedMorph {} + TypedMorph[Symbol.for('lively-module-meta')] = { + package: { name: 'local://widgets' }, + pathInPackage: 'typed-morph.js' + }; + const source = `const Example = component({ + name: 'root', + submorphs: [] +});`; + const root = { + id: 'runtime-root', name: 'root', owner: null, + env: { undoManager: new UndoManager() } + }; + const introduced = { + id: 'runtime-typed', name: 'typed', owner: root, + spec: () => ({ name: 'typed', type: TypedMorph, submorphs: [] }) + }; + root.submorphs = [introduced]; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://typed-introduction-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { _dependants: new Set() }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://typed-introduction-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const resolveMorph = id => id === root.id + ? root + : id === introduced.id ? introduced : null; + + const result = tracker.processCommittedChangeSet(changeSet(new MoveMorph({ + morphId: introduced.id, + from: detachedMorph(), + to: attachedMorph({ ownerId: root.id, index: 0 }) + })), { + legacyChanges: [{}], + resolveMorph + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.componentModule._source) + .includes('import { TypedMorph } from "local://widgets/typed-morph.js";'); + expect(tracker.componentModule._source) + .includes('{ name: "typed", type: TypedMorph }'); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(root.submorphs).deep.equals([]); + + root.env.undoManager.redo(); + expect(tracker.componentModule._source) + .includes('{ name: "typed", type: TypedMorph }'); + expect(root.submorphs).deep.equals([introduced]); + }); + + it('cuts a source-located part introduction over without flattening its base', () => { + class DerivedMorph {} + const source = `const Example = component({ + name: 'root', + submorphs: [] +});`; + const root = { + id: 'runtime-root', name: 'root', owner: null, + env: { undoManager: new UndoManager() } + }; + const inherited = { id: 'runtime-inherited', name: 'inherited', owner: null }; + const introduced = { + id: 'runtime-part', name: 'button', owner: root, + submorphs: [inherited], + master: { + _originalSpec: { name: 'button', opacity: 0.5 }, + parent: { + [Symbol.for('lively-module-meta')]: { + exportedName: 'Button', + moduleId: 'local://widgets/button.cp.js', + path: [] + } + } + }, + spec: () => ({ + name: 'button', + type: DerivedMorph, + opacity: 0.5, + fill: 'inherited-fill', + submorphs: [{ name: 'inherited', submorphs: [] }] + }) + }; + inherited.owner = introduced; + root.submorphs = [introduced]; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://part-introduction-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { _dependants: new Set() }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://part-introduction-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const resolveMorph = id => id === root.id + ? root + : id === introduced.id ? introduced : id === inherited.id ? inherited : null; + + const result = tracker.processCommittedChangeSet(changeSet(new MoveMorph({ + morphId: introduced.id, + from: detachedMorph(), + to: attachedMorph({ ownerId: root.id, index: 0 }) + })), { + legacyChanges: [{}], + resolveMorph + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.componentModule._source) + .includes('import { Button } from "local://widgets/button.cp.js";'); + expect(tracker.componentModule._source).includes('import { part } from "lively.morphic";'); + expect(tracker.componentModule._source) + .includes('part(Button, { name: "button", opacity: 0.5, submorphs: [{ name: "inherited" }] })'); + expect(tracker.componentModule._source).not.includes('inherited-fill'); + expect(tracker.componentModule._source).includes('name: "inherited"'); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(root.submorphs).deep.equals([]); + + root.env.undoManager.redo(); + expect(tracker.componentModule._source) + .includes('part(Button, { name: "button", opacity: 0.5, submorphs: [{ name: "inherited" }] })'); + expect(root.submorphs).deep.equals([introduced]); + }); + + it('cuts an appended introduction into a modeled tiling layout over', () => { + const source = `const Example = component({ + name: 'root', + layout: new TilingLayout({ + resizePolicies: [['first', { height: 'fixed', width: 'fill' }]] + }), + submorphs: [{ name: 'first' }] +});`; + const root = { + id: 'runtime-root', name: 'root', owner: null, + layout: new RuntimeTilingLayoutState([ + ['first', { height: 'fixed', width: 'fill' }] + ]), + env: { undoManager: new UndoManager() } + }; + const first = { id: 'runtime-first', name: 'first', owner: root }; + const introduced = { + id: 'runtime-introduced', name: 'introduced', owner: root, + spec: () => ({ name: 'introduced', fill: 'green', submorphs: [] }) + }; + root.submorphs = [first, introduced]; + installLayoutAwareMorphOperations(root); + installRemovableMorphOperation(introduced); + root.layout.onSubmorphAdded(introduced); + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://layout-introduction-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { _dependants: new Set() }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://layout-introduction-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const targets = new Map([root, first, introduced].map(morph => [morph.id, morph])); + + const result = tracker.processCommittedChangeSet(changeSet(new MoveMorph({ + morphId: introduced.id, + from: detachedMorph(), + to: attachedMorph({ ownerId: root.id, index: 1 }) + })), { + legacyChanges: [{}], + resolveMorph: id => targets.get(id) + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.componentModule._source) + .includes('{ name: "introduced", fill: "green" }'); + expect(tracker.componentModule._source) + .includes("resizePolicies: [['first', { height: 'fixed', width: 'fill' }]]"); + expect(root.layout.resizePolicyFor(introduced)) + .deep.equals({ width: 'fixed', height: 'fixed' }); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(root.submorphs).deep.equals([first]); + expect(root.layout.resizePolicyFor(introduced)).equals(null); + + root.env.undoManager.redo(); + expect(root.submorphs).deep.equals([first, introduced]); + expect(root.layout.resizePolicyFor(introduced)) + .deep.equals({ width: 'fixed', height: 'fixed' }); + }); + + it('cuts over introduction before an existing sibling', () => { + const source = `const Example = component({ + name: 'root', + submorphs: [{ name: 'last' }] +});`; + const root = { id: 'runtime-root', name: 'root', owner: null }; + const introduced = { + id: 'runtime-introduced', name: 'introduced', owner: root, + spec: () => ({ name: 'introduced', submorphs: [] }) + }; + const last = { id: 'runtime-last', name: 'last', owner: root }; + root.submorphs = [introduced, last]; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://introduction-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { _dependants: new Set() } + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://introduction-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const resolveMorph = id => id === root.id + ? root + : id === introduced.id ? introduced : id === last.id ? last : null; + + const result = tracker.processCommittedChangeSet(changeSet(new MoveMorph({ + morphId: introduced.id, + from: detachedMorph(), + to: attachedMorph({ ownerId: root.id, index: 0 }) + })), { + legacyChanges: [{}], + resolveMorph + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.componentModule._source.indexOf('introduced')) + .below(tracker.componentModule._source.indexOf('last')); + expect(tracker._projectionalDocument.root.children.map(({ name }) => name)) + .deep.equals(['introduced', 'last']); + }); + + it('detaches a directly introduced child when structural source commit fails', () => { + const source = `const Example = component({ name: 'root' });`; + const root = { id: 'runtime-root', name: 'root', owner: null }; + const introduced = { + id: 'runtime-introduced', name: 'introduced', owner: root, + spec: () => ({ name: 'introduced', submorphs: [] }) + }; + root.submorphs = [introduced]; + const sourceError = new Error('introduction source cutover failed'); + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://introduction-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { + if (nextSource !== source) throw sourceError; + this._source = nextSource; + } + }; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { _dependants: new Set() }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://introduction-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const resolveMorph = id => id === root.id + ? root + : id === introduced.id ? introduced : null; + + let error; + try { + tracker.processCommittedChangeSet(changeSet(new MoveMorph({ + morphId: introduced.id, + from: detachedMorph(), + to: attachedMorph({ ownerId: root.id, index: 0 }) + })), { + legacyChanges: [{}], + resolveMorph + }); + } catch (caughtError) { + error = caughtError; + } + + expect(error).equals(sourceError); + expect(tracker.componentModule._source).equals(source); + expect(root.submorphs).deep.equals([]); + expect(introduced.owner).equals(null); + }); + + it('cuts static full-text replacement over as an explicit text command', () => { + const source = `const Example = component({ + name: 'label', + textAndAttributes: ['before', null] +});`; + const before = ['before', null]; + const after = ['after', { fontWeight: 'bold' }]; + const root = { + id: 'runtime-root', name: 'label', textAndAttributes: after, owner: null, + env: { undoManager: new UndoManager() } + }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://text-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + const policySpec = { name: 'label', textAndAttributes: before }; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { spec: policySpec }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://text-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const result = tracker.processCommittedChangeSet(changeSet(new SetMorphProperty({ + targetId: root.id, + property: 'textAndAttributes', + before, + after + })), { + legacyChanges: [{}], + resolveMorph: id => id === root.id ? root : null + }); + + expect(result.commands[0].kind).equals(ComponentBridgeCommandKind.EDIT_TEXT); + expect(result.projectionalCommit).not.equals(null); + expect(tracker.componentModule._source) + .includes('textAndAttributes: ["after", { "fontWeight": "bold" }]'); + expect(root.textAndAttributes).equals(after); + expect(policySpec.textAndAttributes).equals(after); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(root.textAndAttributes).deep.equals(before); + expect(policySpec.textAndAttributes).equals(before); + + root.env.undoManager.redo(); + expect(tracker.componentModule._source) + .includes('textAndAttributes: ["after", { "fontWeight": "bold" }]'); + expect(root.textAndAttributes).deep.equals(after); + expect(policySpec.textAndAttributes).equals(after); + }); + + it('rejects rich text values outside the static semantic subset', () => { + class RuntimeTextAttribute {} + const source = `const Example = component({ textAndAttributes: ['before', null] });`; + const root = { + id: 'runtime-root', + name: 'label', + textAndAttributes: ['after', new RuntimeTextAttribute()], + owner: null + }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://text-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { componentName: 'Example' }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://text-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + + const result = tracker.processCommittedChangeSet(changeSet(new SetMorphProperty({ + targetId: root.id, + property: 'textAndAttributes', + before: ['before', null], + after: root.textAndAttributes + })), { + legacyChanges: [{}], + resolveMorph: id => id === root.id ? root : null + }); + + expect(result.shadowProjection.supported).to.be.false; + expect(result.projectionalCommit).equals(null); + expect(tracker.componentModule._source).equals(source); + }); + + it('cuts serializable master changes over with imports and exact undo and redo', () => { + class SerializableMaster { + getConfigAsExpression () { + return { + __expr__: 'HoverMaster', + bindings: { 'local://masters.js': ['HoverMaster'] } + }; + } + } + const source = `const Example = component({ master: null });`; + const nextMaster = new SerializableMaster(); + const root = { + id: 'runtime-root', name: 'example', master: nextMaster, owner: null, + env: { undoManager: new UndoManager() } + }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://master-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://master-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + + const result = tracker.processCommittedChangeSet(changeSet(new SetMorphProperty({ + targetId: root.id, + property: 'master', + before: null, + after: nextMaster + })), { + legacyChanges: [{}], + resolveMorph: id => id === root.id ? root : null + }); + + expect(result.commands[0].kind).equals(ComponentBridgeCommandKind.SET_MASTER); + expect(result.projectionalCommit, + JSON.stringify(result.shadowProjection?.diagnostics || [])) + .not.equals(null); + expect(tracker.componentModule._source) + .includes('import { HoverMaster } from "local://masters.js";'); + expect(tracker.componentModule._source).includes('master: HoverMaster'); + expect(root.master).equals(nextMaster); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(root.master).equals(null); + + root.env.undoManager.redo(); + expect(tracker.componentModule._source).includes('master: HoverMaster'); + expect(root.master).equals(nextMaster); + }); + + it('cuts over a direct master change that clears the local policy', () => { + const previousMaster = { getConfigAsExpression: () => ({ __expr__: 'BaseMaster', bindings: {} }) }; + const source = `const Example = component({ master: BaseMaster });`; + const root = { id: 'runtime-root', name: 'example', master: null, owner: null }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://master-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { componentName: 'Example' }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://master-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + + const result = tracker.processCommittedChangeSet(changeSet(new SetMorphProperty({ + targetId: root.id, + property: 'master', + before: previousMaster, + after: null + })), { + legacyChanges: [{}], + resolveMorph: id => id === root.id ? root : null + }); + + expect(result.shadowProjection.supported).to.be.true; + expect(result.projectionalCommit).not.equals(null); + expect(tracker.componentModule._source).includes('master: null'); + }); + + it('keeps semantic document revisions monotonic across scalar cutover edits', () => { + const source = `const Example = component({ + name: 'example', + fill: 'red' +});`; + const root = { id: 'runtime-root', name: 'example', fill: 'green', owner: null }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://scalar-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://scalar-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const context = { + legacyChanges: [{}], + resolveMorph: id => id === root.id ? root : null + }; + + tracker.processCommittedChangeSet(changeSet(new SetMorphProperty({ + targetId: root.id, property: 'fill', before: 'red', after: 'green' + })), context); + root.fill = 'blue'; + context.legacyChanges = [{}]; + tracker.processCommittedChangeSet(changeSet(new SetMorphProperty({ + targetId: root.id, property: 'fill', before: 'green', after: 'blue' + })), context); + + expect(tracker._projectionalDocument.revision).equals(2); + expect(tracker.componentModule._source).includes('fill: "blue"'); + expect(root.fill).equals('blue'); + }); + + it('cuts a scalar property batch over as one exact undoable transaction', () => { + const source = `const Example = component({ + name: 'example', + fill: 'red', + opacity: 0.5 +});`; + const root = { + id: 'runtime-root', + name: 'example', + fill: 'green', + opacity: 0.8, + owner: null, + env: { undoManager: new UndoManager() } + }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://batch-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://batch-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + + const result = tracker.processCommittedChangeSet(changeSet([ + new SetMorphProperty({ + targetId: root.id, + property: 'fill', + before: 'red', + after: 'green' + }), + new SetMorphProperty({ + targetId: root.id, + property: 'opacity', + before: 0.5, + after: 0.8 + }) + ]), { + legacyChanges: [{}, {}], + resolveMorph: id => id === root.id ? root : null + }); + + expect(result.projectionalCommit).not.equals(null); + expect(result.projectionalCommit.transaction.commands).length(2); + expect(tracker.componentModule._source).includes('fill: "green"'); + expect(tracker.componentModule._source).includes('opacity: 0.8'); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(root.fill).equals('red'); + expect(root.opacity).equals(0.5); + + root.env.undoManager.redo(); + expect(tracker.componentModule._source).includes('fill: "green"'); + expect(root.fill).equals('green'); + expect(root.opacity).equals(0.8); + }); + + it('clears an override from explicit semantic intent before mutating runtime', () => { + const source = `const Example = component({ + name: 'example', + fill: 'red' +});`; + const root = { id: 'runtime-root', name: 'example', fill: 'red', owner: null }; + const undoManager = new UndoManager(); + root.env = { undoManager }; + const descriptorCalls = []; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://explicit-clear/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + makeDirty: () => descriptorCalls.push('dirty'), + refreshDependants: () => descriptorCalls.push('refresh') + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://explicit-clear/component.cp.js::Example', + containsMorph: () => true + }); + + const result = tracker.clearPropertyOverride({ + target: root, + property: 'fill', + effectiveValue: 'inherited-blue' + }); + + expect(result.committed).to.be.true; + expect(result.editTransaction).to.be.instanceOf(ProjectionalComponentEditTransaction); + expect(tracker.componentModule._source).not.includes('fill:'); + expect(tracker._projectionalDocument.root.properties).not.haveOwnProperty('fill'); + expect(root.fill).equals('inherited-blue'); + expect(undoManager.undos).to.have.length(1); + expect(undoManager.undos[0]).equals(result.editTransaction); + + undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(tracker._projectionalDocument.root.properties.fill.value).equals('red'); + expect(root.fill).equals('red'); + + undoManager.redo(); + expect(tracker.componentModule._source).not.includes('fill:'); + expect(tracker._projectionalDocument.root.properties).not.haveOwnProperty('fill'); + expect(root.fill).equals('inherited-blue'); + expect(descriptorCalls).deep.equals([ + 'dirty', 'refresh', + 'dirty', 'refresh', + 'dirty', 'refresh' + ]); + }); + + it('sets a semantic property from explicit intent with exact undo and redo', () => { + const source = `const Example = component({ name: 'example' });`; + const root = { + id: 'runtime-root', + name: 'example', + visible: true, + owner: null, + env: { undoManager: new UndoManager() } + }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://explicit-set/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://explicit-set/component.cp.js::Example' + }); + + const result = tracker.setProperty({ + target: root, + property: 'visible', + value: false + }); + + expect(result.committed).to.be.true; + expect(tracker.componentModule._source).includes('visible: false'); + expect(tracker._projectionalDocument.root.properties.visible.value).to.be.false; + expect(root.visible).to.be.false; + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(tracker._projectionalDocument.root.properties).not.haveOwnProperty('visible'); + expect(root.visible).to.be.true; + + root.env.undoManager.redo(); + expect(tracker.componentModule._source).includes('visible: false'); + expect(root.visible).to.be.false; + }); + + it('rejects an explicit property before mutation when its value is not serializable', () => { + class RuntimeOnlyValue {} + const source = `const Example = component({ name: 'example' });`; + const previous = new RuntimeOnlyValue(); + const next = new RuntimeOnlyValue(); + const root = { + id: 'runtime-root', name: 'example', customStyle: previous, owner: null + }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://explicit-set/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://explicit-set/component.cp.js::Example' + }); + + const result = tracker.setProperty({ + target: root, + property: 'customStyle', + value: next + }); + + expect(result.committed).to.be.false; + expect(result.diagnostics[0].kind) + .equals(ProjectionalCommandDiagnosticKind.PLANNING_FAILED); + expect(tracker.componentModule._source).equals(source); + expect(root.customStyle).equals(previous); + }); + + it('sets a serializer-backed property with its import and exact undo and redo', () => { + class SerializableColor { + constructor (name) { this.name = name; } + __serialize__ () { + return { + __expr__: `Color.${this.name}`, + bindings: { 'lively.graphics': ['Color'] } + }; + } + } + const source = `const Example = component({ fill: 'red' });`; + const previous = new SerializableColor('red'); + const next = new SerializableColor('green'); + const root = { + id: 'runtime-root', name: 'example', fill: previous, owner: null, + env: { undoManager: new UndoManager() } + }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://opaque-set/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://opaque-set/component.cp.js::Example' + }); + + const result = tracker.setProperty({ target: root, property: 'fill', value: next }); + + expect(result.committed).to.be.true; + expect(tracker.componentModule._source).includes('import { Color } from "lively.graphics";'); + expect(tracker.componentModule._source).includes('fill: Color.green'); + expect(root.fill).equals(next); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(root.fill).equals(previous); + + root.env.undoManager.redo(); + expect(tracker.componentModule._source).includes('fill: Color.green'); + expect(root.fill).equals(next); + }); + + it('finds the nearest component tracker for an edited descendant', () => { + const tracker = { + tracksMorph: morph => morph.name === 'child' + }; + const root = { name: 'root', owner: null, _changeTracker: tracker }; + const child = { name: 'child', owner: root }; + + expect(componentChangeTrackerFor(child)).equals(tracker); + expect(componentChangeTrackerFor({ name: 'detached', owner: null })).equals(null); + }); + + it('uses explicit component intent when available and otherwise performs ordinary mutation', () => { + const projectionalTarget = { + owner: null, + visible: true, + _changeTracker: { + tracksMorph: () => true, + setProperty: options => Object.freeze({ committed: true, options }) + }, + withMetaDo: () => { throw new Error('must not mutate before a committed command'); } + }; + const committed = setMorphPropertyWithComponentCommand({ + target: projectionalTarget, + property: 'visible', + value: false + }); + + expect(committed.committed).to.be.true; + expect(projectionalTarget.visible).to.be.true; + + const metadata = []; + const untrackedTarget = { + owner: null, + visible: true, + withMetaDo: (meta, callback) => { + metadata.push(meta); + callback(); + } + }; + const directMutationResult = setMorphPropertyWithComponentCommand({ + target: untrackedTarget, + property: 'visible', + value: false + }); + + expect(directMutationResult).equals(null); + expect(untrackedTarget.visible).to.be.false; + expect(metadata).deep.equals([]); + }); + + it('joins an explicit clear command into an active undo while replacing recorded Morphic changes', () => { + const source = `const Example = component({ fill: 'red' });`; + const journalCalls = []; + const root = { + id: 'runtime-root', + name: 'example', + fill: 'red', + owner: null, + env: { + undoManager: { + undoInProgress: {}, + addTransaction: (transaction, options) => { + journalCalls.push([transaction, options]); + return transaction; + } + } + } + }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://explicit-clear/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://explicit-clear/component.cp.js::Example' + }); + + const result = tracker.clearPropertyOverride({ + target: root, + property: 'fill', + effectiveValue: null + }); + + expect(result.committed).to.be.true; + expect(journalCalls).to.have.length(1); + expect(journalCalls[0][0]).equals(result.editTransaction); + expect(journalCalls[0][1]).deep.equals({ joinActive: true }); + }); + + it('leaves every domain unchanged when an explicit clear is unsupported', () => { + const source = `const Example = component({ name: 'example' });`; + const root = { + id: 'runtime-root', name: 'example', fill: 'inherited-blue', owner: null + }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://explicit-clear/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://explicit-clear/component.cp.js::Example' + }); + + const result = tracker.clearPropertyOverride({ + target: root, + property: 'fill', + effectiveValue: null + }); + + expect(result.committed).to.be.false; + expect(result.diagnostics[0].kind) + .equals(ProjectionalCommandDiagnosticKind.PLANNING_FAILED); + expect(tracker.componentModule._source).equals(source); + expect(root.fill).equals('inherited-blue'); + expect(tracker._projectionalDocument).equals(undefined); + }); + + it('leaves runtime and history unchanged when an explicit clear cannot commit source', () => { + const source = `const Example = component({ fill: 'red' });`; + const sourceError = new Error('explicit clear source failed'); + const undoManager = new UndoManager(); + const root = { + id: 'runtime-root', + name: 'example', + fill: 'red', + owner: null, + env: { undoManager } + }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://explicit-clear/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { + if (nextSource !== source) throw sourceError; + this._source = nextSource; + } + }; + tracker.componentDescriptor = { + componentName: 'Example', + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://explicit-clear/component.cp.js::Example' + }); + + let error; + try { + tracker.clearPropertyOverride({ + target: root, + property: 'fill', + effectiveValue: 'inherited-blue' + }); + } catch (caughtError) { + error = caughtError; + } + + expect(error).equals(sourceError); + expect(tracker.componentModule._source).equals(source); + expect(root.fill).equals('red'); + expect(undoManager.undos).to.have.length(0); + expect(tracker._projectionalDocument).equals(undefined); + }); + + it('cuts a root component rename over with exact undo and redo', () => { + const source = `const Example = component({ name: 'before' });`; + const root = { + id: 'runtime-root', name: 'after', owner: null, + env: { undoManager: new UndoManager() } + }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://scalar-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + stylePolicy: { spec: { name: 'before' }, _dependants: new Set() }, + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://scalar-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const recordedChange = { meta: { reconcileChanges: true } }; + + const result = tracker.processCommittedChangeSet(changeSet(new SetMorphProperty({ + targetId: root.id, property: 'name', before: 'before', after: 'after' + })), { + legacyChanges: [recordedChange], + resolveMorph: id => id === root.id ? root : null + }); + + expect(result.projectionalCommit).not.equals(null); + expect(tracker.lastShadowCommandBatch).not.haveOwnProperty('renameDiagnostic'); + expect(tracker.componentModule._source).includes('name: "after"'); + expect(root.name).equals('after'); + expect(tracker.componentDescriptor.stylePolicy.spec.name).equals('after'); + + root.env.undoManager.undo(); + expect(tracker.componentModule._source).equals(source); + expect(root.name).equals('before'); + expect(tracker.componentDescriptor.stylePolicy.spec.name).equals('before'); + + root.env.undoManager.redo(); + expect(tracker.componentModule._source).includes('name: "after"'); + expect(root.name).equals('after'); + expect(tracker.componentDescriptor.stylePolicy.spec.name).equals('after'); + }); + + it('commits scalar cutover together with required source imports', () => { + const source = `const Example = component({ fill: Color.red });`; + const runtimeValue = { + __serialize__: () => ({ + __expr__: 'Color.green', + bindings: { 'lively.graphics': ['Color'] } + }) + }; + const root = { + id: 'runtime-root', name: 'example', fill: runtimeValue, owner: null + }; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://scalar-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { this._source = nextSource; } + }; + tracker.componentDescriptor = { + componentName: 'Example', + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://scalar-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + + const result = tracker.processCommittedChangeSet(changeSet(new SetMorphProperty({ + targetId: root.id, + property: 'fill', + before: { color: 'red' }, + after: runtimeValue + })), { + legacyChanges: [{}], + resolveMorph: id => id === root.id ? root : null + }); + + expect(result.shadowProjection.supported).to.be.true; + expect(result.shadowProjection.requiredBindings) + .deep.equals({ 'lively.graphics': ['Color'] }); + expect(result.projectionalCommit).not.equals(null); + expect(tracker.componentModule._source).includes('import { Color } from "lively.graphics";'); + expect(tracker.componentModule._source).includes('fill: Color.green'); + expect(tracker._projectionallyConsumedChanges).to.be.instanceOf(WeakSet); + }); + + it('rolls direct runtime state back when scalar cutover source commit fails', () => { + const source = `const Example = component({ fill: 'red' });`; + const root = { id: 'runtime-root', name: 'example', fill: 'green', owner: null }; + const sourceError = new Error('source cutover failed'); + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker.trackedComponent = root; + tracker.componentModuleId = 'local://scalar-cutover/component.cp.js'; + tracker.componentModule = { + _source: source, + setSource (nextSource) { + if (nextSource !== source) throw sourceError; + this._source = nextSource; + } + }; + tracker.componentDescriptor = { + componentName: 'Example', + makeDirty: () => {}, + refreshDependants: () => {} + }; + tracker.committedChangeAdapter = new MorphicChangeSetAdapter({ + componentId: 'local://scalar-cutover/component.cp.js::Example', + containsMorph: () => true + }); + tracker.scheduleShadowProjectionComparison = () => null; + const committed = changeSet(new SetMorphProperty({ + targetId: root.id, property: 'fill', before: 'red', after: 'green' + })); + + let error; + try { + tracker.processCommittedChangeSet(committed, { + legacyChanges: [{}], + resolveMorph: id => id === root.id ? root : null + }); + } catch (caughtError) { + error = caughtError; + } + + expect(error).equals(sourceError); + expect(tracker.componentModule._source).equals(source); + expect(root.fill).equals('red'); + }); + + it('releases the committed change listener when a tracker is disposed', () => { + const removed = []; + const tracker = Object.create(ComponentChangeTracker.prototype); + tracker._committedChangeListener = () => {}; + tracker.trackedComponent = { + env: { + changeManager: { + removeCommittedChangeListener: listener => removed.push(listener) + } + }, + _changeTracker: tracker + }; + + tracker.dispose(); + + expect(removed).deep.equals([tracker._committedChangeListener]); + expect(tracker.trackedComponent).not.haveOwnProperty('_changeTracker'); + }); +}); diff --git a/lively.ide/tests/components/reconciliation-fuzz-test.js b/lively.ide/tests/components/reconciliation-fuzz-test.js new file mode 100644 index 0000000000..27e3e83967 --- /dev/null +++ b/lively.ide/tests/components/reconciliation-fuzz-test.js @@ -0,0 +1,501 @@ +/* global describe, it, afterEach, System */ +import { expect } from 'mocha-es6'; +import { createFiles, resource } from 'lively.resources'; +import module from 'lively.modules/src/module.js'; +import { parse } from 'lively.ast'; +import { + createReconciliationFuzzer, + DEFAULT_RECONCILIATION_FUZZ_OPERATIONS, + RECONCILIATION_FUZZ_STABLE_STYLE_PROPERTIES, + reconciliationFuzzBaseSource, + reconciliationFuzzSubjectSource, + SeededRandom +} from '../../components/debug.js'; + +const testRoot = 'local://component-reconciliation-fuzz-test/'; +const componentMetaSymbol = Symbol.for('lively-module-meta'); +const immediatelyValidatedOperations = new Set([ + 'changeText', + 'editTextRange', + 'burstTextEdits', + 'changeRichText', + 'insertEmbeddedMorph', + 'removeEmbeddedMorph', + 'updateEmbeddedMorph', + 'changeMaster', + 'clearMasterState', + 'addModelPart', + 'addPartWithNestedAddition', + 'cycleInheritedSuppression', + 'reintroduceMorph', + 'reparentInheritedMorph', + 'undoTransaction', + 'redoTransaction' +]); +let loadedModules = []; + +function componentStructureSnapshot (component, styleProperties = []) { + const stylePropertiesByPath = new Map(); + for (const { path, property } of styleProperties) { + const pathKey = JSON.stringify(path); + const properties = stylePropertiesByPath.get(pathKey) || new Set(); + properties.add(property); + stylePropertiesByPath.set(pathKey, properties); + } + const snapshotValue = (value, seen = new WeakSet()) => { + if (value == null || typeof value !== 'object') return value; + if (value.isColor || value.isPoint || value.isRectangle) return value.toString(); + const componentMeta = value[componentMetaSymbol]; + if ((value.isComponentDescriptor || value.isPolicy) && componentMeta) { + return { + component: componentMeta.exportedName || componentMeta.export, + moduleId: componentMeta.moduleId || componentMeta.module, + path: componentMeta.path || [] + }; + } + if (seen.has(value)) return ''; + seen.add(value); + if (Array.isArray(value)) { + return value.map(item => snapshotValue(item, seen)); + } + return Object.fromEntries(Object.keys(value) + .filter(key => typeof value[key] !== 'function') + .sort() + .map(key => [key, snapshotValue(value[key], seen)])); + }; + const snapshotTextAndAttributes = morph => { + if (!morph.isText) return null; + const runs = []; + for (let index = 0; index < morph.textAndAttributes.length; index += 2) { + const content = morph.textAndAttributes[index]; + runs.push({ + content: content?.isMorph + ? { embeddedMorph: content.name, type: content.constructor.name } + : snapshotValue(content), + attributes: snapshotValue(morph.textAndAttributes[index + 1] || null) + }); + } + return runs; + }; + const snapshotViewModel = viewModel => { + if (!viewModel) return null; + return Object.fromEntries(Object.keys(viewModel._viewState || {}) + .filter(key => key !== 'view') + .sort() + .map(key => [key, snapshotValue(viewModel._viewState[key])])); + }; + const snapshotMaster = master => { + if (!master) return null; + const effectiveConfig = {}; + const visitedPolicies = new Set(); + for (let policy = master; policy && !visitedPolicies.has(policy); policy = policy.parent) { + visitedPolicies.add(policy); + const config = policy.getConfig?.(); + for (const key of ['hover', 'click', 'states', 'breakpoints']) { + if (config?.[key] && effectiveConfig[key] === undefined) { + effectiveConfig[key] = config[key]; + } + } + } + return Object.keys(effectiveConfig).length > 0 + ? snapshotValue(effectiveConfig) + : null; + }; + const normalizedPathVertices = morph => { + const width = morph.width || 1; + const height = morph.height || 1; + const normalized = value => Math.round(value * 1000000) / 1000000; + return morph.vertices.map(({ position }) => ({ + x: normalized(position.x / width), + y: normalized(position.y / height) + })); + }; + const snapshotMorph = (morph, isRoot = false, path = []) => { + const semanticSubmorphs = morph.isText + ? morph.textAndAttributes.filter(value => value?.isMorph) + : morph.submorphs; + const submorphs = semanticSubmorphs + .map(submorph => snapshotMorph(submorph, false, [...path, submorph.name])); + const explicitlyComparedProperties = stylePropertiesByPath.get(JSON.stringify(path)) || []; + const comparedStyleProperties = new Set(explicitlyComparedProperties); + return { + type: morph.constructor.name, + // The edit proxy intentionally gives the root a canonical editor name + // and presentation; parity starts at the component's managed children. + name: isRoot ? null : morph.name, + text: morph.isText ? morph.textString : null, + textAndAttributes: snapshotTextAndAttributes(morph), + // A parent layout may resize a path during cold instantiation while the + // detached editable instance has not rendered. Path shape is semantic; + // layout-controlled absolute geometry is not, so compare its normalized + // vertices and let the layout snapshot cover the resizing policy. + vertices: morph.isPath ? normalizedPathVertices(morph) : null, + style: Object.fromEntries([...comparedStyleProperties].sort() + .map(property => [property, snapshotValue(morph[property])])), + layout: morph.layout + ? { + type: morph.layout.constructor.name, + axis: morph.layout.axis, + align: morph.layout.align, + axisAlign: morph.layout.axisAlign, + justifySubmorphs: morph.layout.justifySubmorphs, + padding: morph.layout.padding?.toString(), + spacing: morph.layout.spacing, + orderByIndex: morph.layout.orderByIndex, + wrapSubmorphs: morph.layout.wrapSubmorphs, + columnCount: morph.layout.columnCount, + rowCount: morph.layout.rowCount, + renderViaCSS: morph.layout.renderViaCSS, + resizePolicies: morph.layout.resizePolicies + ?.map(([name, policy]) => [name, snapshotValue(policy)]) + } + : null, + master: snapshotMaster(morph.master), + viewModel: snapshotViewModel(morph.viewModel), + submorphs + }; + }; + return snapshotMorph(component, true); +} + +function reconciliationOracleFixture ({ + fontWeight = 'bold', + embeddedFill = 'red', + modelEnabled = true, + masterName = 'MasterA' +} = {}) { + const fakeMorph = ({ + name, + type = 'Morph', + style = {}, + submorphs = [], + textAndAttributes = null, + viewState = null, + master = null + }) => ({ + constructor: { name: type }, + name, + isMorph: true, + isText: !!textAndAttributes, + isPath: false, + textString: textAndAttributes ? 'same text' : null, + textAndAttributes, + styleProperties: Object.keys(style), + ...style, + submorphs, + layout: null, + master, + viewModel: viewState ? { _viewState: viewState } : null, + owner: null + }); + const componentReference = name => ({ + isComponentDescriptor: true, + [componentMetaSymbol]: { + moduleId: 'local://reconciliation-oracle-fixture.cp.js', + exportedName: name, + path: [] + } + }); + const embeddedMorph = fakeMorph({ + name: 'embedded morph', + style: { fill: embeddedFill } + }); + const textMorph = fakeMorph({ + name: 'rich text', + type: 'Text', + textAndAttributes: [ + 'same', { fontWeight }, + embeddedMorph, { fontStyle: 'normal' } + ] + }); + const modelPart = fakeMorph({ + name: 'model part', + style: { fill: 'blue' }, + viewState: { + view: { deliberatelyIgnored: true }, + flags: [true, false], + nested: { enabled: modelEnabled } + }, + master: { + getConfig: () => ({ hover: componentReference(masterName) }) + } + }); + const root = fakeMorph({ + name: 'editable component root', + submorphs: [textMorph, modelPart] + }); + textMorph.owner = root; + modelPart.owner = root; + embeddedMorph.owner = textMorph; + return root; +} + +function firstSnapshotDifference (coldValue, editableValue, path = []) { + if (Object.is(coldValue, editableValue)) return null; + if (!coldValue || !editableValue || + typeof coldValue !== 'object' || typeof editableValue !== 'object') { + return { path, coldValue, editableValue }; + } + const keys = new Set([...Object.keys(coldValue), ...Object.keys(editableValue)]); + for (const key of keys) { + const difference = firstSnapshotDifference(coldValue[key], editableValue[key], [...path, key]); + if (difference) return difference; + } + return null; +} + +function duplicateWithoutTargets (source) { + const duplicates = []; + const visit = node => { + if (!node || typeof node !== 'object') return; + if (node.type === 'ArrayExpression') { + const targets = node.elements.map(element => { + if (element?.type !== 'CallExpression' || + element.callee?.type !== 'Identifier' || + element.callee.name !== 'without' || + element.arguments.length !== 1) return null; + const [argument] = element.arguments; + return argument?.type === 'Literal' && typeof argument.value === 'string' + ? argument.value + : null; + }).filter(Boolean); + const seen = new Set(); + for (const target of targets) { + if (seen.has(target)) duplicates.push(target); + seen.add(target); + } + } + for (const [key, value] of Object.entries(node)) { + if (['loc', 'sourceFile'].includes(key)) continue; + if (Array.isArray(value)) value.forEach(visit); + else visit(value); + } + }; + visit(parse(source)); + return duplicates; +} + +async function cleanup () { + for (const loadedModule of loadedModules.reverse()) { + await loadedModule.unload({ forgetDeps: false }); + } + loadedModules = []; + await resource(testRoot).remove(); +} + +async function prepareFuzzer (seed, steps) { + const projectName = `seed-${seed}`; + const projectRoot = `${testRoot}${projectName}/`; + const baseModuleId = `${projectRoot}base.cp.js`; + const subjectModuleId = `${projectRoot}subject.cp.js`; + const subjectSource = reconciliationFuzzSubjectSource(baseModuleId); + + await createFiles(testRoot, { + [projectName]: { + 'package.json': JSON.stringify({ name: `reconciliation-fuzz-${seed}`, main: 'subject.cp.js' }), + 'base.cp.js': reconciliationFuzzBaseSource, + 'subject.cp.js': subjectSource + } + }); + + const baseModule = module(System, baseModuleId); + const subjectModule = module(System, subjectModuleId); + loadedModules.push(baseModule, subjectModule); + + return createReconciliationFuzzer({ + baseModuleId, + subjectModuleId, + resetSource: false, + seed, + validateSource: async (source, { step, operation, action, component, styleProperties }) => { + if (operation === 'changeMaster') { + expect(source).to.match(new RegExp(`${action.state}:\\s*${action.component}`)); + } + expect( + duplicateWithoutTargets(source), + 'without() markers must be unique within each submorph scope' + ).to.eql([]); + if ((step + 1) % 7 !== 0 && + step !== steps - 1 && + !immediatelyValidatedOperations.has(operation)) return; + parse(source); + const validationModuleId = `${projectRoot}validation-${step}.cp.js`; + const validationResource = resource(validationModuleId); + const validationModule = module(System, validationModuleId); + try { + await validationResource.write(source); + const { Subject } = await validationModule.load(); + if (!Subject?.isComponentDescriptor) throw new Error('Reconciled source did not evaluate to a component descriptor'); + const validatedComponent = Subject.derive(); + const validatedSnapshot = JSON.parse(JSON.stringify( + componentStructureSnapshot(validatedComponent, styleProperties))); + const editableSnapshot = JSON.parse(JSON.stringify( + componentStructureSnapshot(component, styleProperties))); + const difference = firstSnapshotDifference(validatedSnapshot, editableSnapshot); + const recentBatches = difference + ? component._changeTracker.shadowCommandBatches.slice(-30) + .filter(batch => batch.commands.some(command => + command.kind === 'rename-node' || + command.kind === 'move-node' || + command.property === 'layout' + )) + .slice(-6) + .map(batch => ({ + commands: batch.commands.map(command => ({ + kind: command.kind, + property: command.property, + previousName: command.previousName, + name: command.name + })), + committed: !!batch.projectionalCommit, + commitDiagnostic: batch.commitDiagnostic?.message, + policyCache: batch.policyCacheProjection && { + kind: batch.policyCacheProjection.kind, + layoutChanges: batch.policyCacheProjection.changes + ?.filter(change => change.property === 'layout') + .map(change => ({ + before: change.beforeValue?.getSpec?.(), + after: change.afterValue?.getSpec?.() + })) + }, + runtimeLayouts: batch.shadowProjection?.steps?.flatMap(step => + step.runtimeProjection?.changeSet?.operations + ?.filter(operation => operation.property === 'layout') + .map(operation => ({ + before: operation.before?.getSpec?.() || operation.before, + after: operation.after?.getSpec?.() || operation.after + })) || [] + ), + shadowSupported: batch.shadowProjection?.supported, + shadowDiagnostics: batch.shadowProjection?.diagnostics?.map( + diagnostic => diagnostic.message + ), + shadowHasNewName: action.newName + ? batch.shadowProjection?.sourceAfter?.includes(action.newName) + : undefined + })) + : []; + expect( + validatedSnapshot, + difference && `first cold/editable mismatch: ${JSON.stringify(difference)}; recent batches: ${JSON.stringify(recentBatches)}` + ).to.eql(editableSnapshot); + } finally { + await validationModule.unload({ forgetDeps: false }); + await validationResource.remove(); + } + } + }); +} + +describe('component reconciliation fuzzer', function () { + this.timeout(180000); + + afterEach(async () => { + await cleanup(); + }); + + it('uses deterministic random sequences', () => { + const first = new SeededRandom('replayable seed'); + const second = new SeededRandom('replayable seed'); + const third = new SeededRandom('different seed'); + const firstSequence = Array.from({ length: 20 }, () => first.next()); + const secondSequence = Array.from({ length: 20 }, () => second.next()); + const thirdSequence = Array.from({ length: 20 }, () => third.next()); + + expect(firstSequence).to.eql(secondSequence); + expect(firstSequence).not.to.eql(thirdSequence); + }); + + it('detects semantic mismatches in previously unobserved component state', () => { + const oracleStyleProperties = RECONCILIATION_FUZZ_STABLE_STYLE_PROPERTIES + .filter(property => property === 'fill') + .map(property => ({ + path: ['rich text', 'embedded morph'], + property + })); + const baseline = componentStructureSnapshot( + reconciliationOracleFixture(), + oracleStyleProperties + ); + const variants = [ + ['rich-text attributes', { fontWeight: 'normal' }, 'textAndAttributes'], + ['embedded-morph styling', { embeddedFill: 'green' }, 'fill'], + ['view-model state', { modelEnabled: false }, 'enabled'], + ['master configuration', { masterName: 'MasterB' }, 'component'] + ]; + + expect(firstSnapshotDifference( + baseline, + componentStructureSnapshot(reconciliationOracleFixture(), oracleStyleProperties) + )).to.equal(null); + for (const [label, variant, expectedPathPart] of variants) { + const difference = firstSnapshotDifference( + baseline, + componentStructureSnapshot(reconciliationOracleFixture(variant), oracleStyleProperties) + ); + expect(difference, `${label} mismatch must be detected`).to.be.ok; + expect(difference.path, `${label} mismatch path`).to.include(expectedPathPart); + } + }); + + it('survives seeded structural and property stress scenarios', async () => { + const seeds = [0xC0FFEE, 0xBAD5EED, 0xDEC0DE, 0xF00DBABE]; + const steps = 64; + const coveredOperations = new Set(); + + for (const seed of seeds) { + const fuzzer = await prepareFuzzer(seed, steps); + const editableText = fuzzer.component.get('fuzz text'); + expect(editableText.readOnly).to.be.false; + expect(editableText.selectable).to.be.true; + expect(editableText.reactsToPointer).to.be.true; + let result; + try { + result = await fuzzer.run(steps); + } catch (error) { + throw new Error(JSON.stringify({ + message: error.message, + cause: error.cause?.message || String(error.cause), + causeStack: error.cause?.stack, + causeChange: error.cause?.change && { + prop: error.cause.change.prop, + selector: error.cause.change.selector, + target: error.cause.change.target?.name, + meta: error.cause.change.meta + }, + causeBatch: error.cause?.batch && { + commands: error.cause.batch.commands?.map(command => ({ + kind: command.kind, + property: command.property + })), + diagnostics: error.cause.batch.diagnostics?.map( + ({ kind, message }) => ({ kind, message }) + ), + shadowSupported: error.cause.batch.shadowProjection?.supported, + shadowDiagnostics: error.cause.batch.shadowProjection?.diagnostics?.map( + ({ kind, message }) => ({ kind, message }) + ), + commitDiagnostic: error.cause.batch.commitDiagnostic + }, + actual: error.cause?.actual, + expected: error.cause?.expected, + seed: error.seed, + step: error.step, + operation: error.operation, + action: error.action, + actions: error.actions, + sourceBefore: error.sourceBefore, + sourceAfter: error.sourceAfter + }, null, 2)); + } + expect(result.actions).to.have.length(steps); + parse(result.source); + expect(result.source).to.include('Base as AliasedBase'); + for (const action of result.actions) coveredOperations.add(action.operation); + } + + for (const operation of DEFAULT_RECONCILIATION_FUZZ_OPERATIONS) { + expect(coveredOperations.has(operation), `expected fuzz operation ${operation} to run`).to.be.true; + } + }); +}); diff --git a/lively.ide/tests/components/reconciliation-test.js b/lively.ide/tests/components/reconciliation-test.js deleted file mode 100644 index 0f8d66146b..0000000000 --- a/lively.ide/tests/components/reconciliation-test.js +++ /dev/null @@ -1,987 +0,0 @@ -/* global it, describe, beforeEach, afterEach, after, System */ -import { expect, chai } from 'mocha-es6'; -import sinonChai from 'sinon-chai'; -import sinon from 'sinon'; -import { createFiles, resource } from 'lively.resources'; -import module from 'lively.modules/src/module.js'; -import { Color, pt, rect } from 'lively.graphics'; -import { morph, add, without, TilingLayout, Label, part } from 'lively.morphic'; -import { Reconciliation } from '../../components/reconciliation.js'; -import { promise } from 'lively.lang'; - -chai.use(sinonChai); - -const initSource = ` -"format esm"; -import { part, component, ComponentDescriptor } from 'lively.morphic/components/core.js'; -import { InteractiveComponentDescriptor } from 'lively.ide/components/editor.js'; -import { Color, pt} from 'lively.graphics'; -import { Text } from "lively.morphic"; - -component.DescriptorClass = InteractiveComponentDescriptor; - -const C = component({ - name: 'C', - fill: Color.grey, -}); - -const D = component({ - name: 'D', - fill: Color.purple, - borderWidth: { top: 0, left: 1, bottom: 2, right: 4 }, - submorphs: [{ - name: 'a deep morph', - fill: Color.orange - }] -}); - -const A = component({ - name: 'A', - fill: Color.red, - extent: pt(100,100), - submorphs: [{ - type: Text, - name: 'some submorph', - extent: pt(50,50), - fixedWidth: true, - fixedHeight: true, - fill: Color.yellow, - },part(D, { name: 'some ref'})] -}); - -const B = component(A, { - name: 'B', - submorphs: [{ - name: 'some submorph', - fill: Color.green - }] -}); - -const X = component(B, { - name: 'X' -}); - -const T = component({ - name: 'T', - submorphs: [{ - name: 'a greeter', - type: 'text', - value: 'hello world' - }, { - name: 'another greeter', - type: 'text', - textString: 'yo bro!' - }] -}); - -component.DescriptorClass = ComponentDescriptor; - -export { A, B, C, D, X, T }; -`; - -let ComponentA, ComponentB, ComponentC, ComponentD, ComponentX, ComponentT, - A, B, C, D, X, T; - -let testDir = 'local://component-reconciliation-test/'; - -async function getSource () { - // delete testComponentModule._source; - return await module(System, testDir + 'project1/test.cp.js').source(); -} - -let project1 = { - 'test.cp.js': initSource, - 'package.json': '{"name": "project1", "main": "test.cp.js"}' -}; -let testResources = { - project1: project1 -}; - -let S; - -async function prepareEnv () { - const modId = testDir + 'project1/test.cp.js'; - await createFiles(testDir, testResources); - const mod = module(System, modId); - ({ A, B, C, D, X, T } = await mod.load()); - A.previouslyRemovedMorphs = new WeakMap(); - B.previouslyRemovedMorphs = new WeakMap(); - C.previouslyRemovedMorphs = new WeakMap(); - D.previouslyRemovedMorphs = new WeakMap(); - T.previouslyRemovedMorphs = new WeakMap(); - ComponentA = await A.edit(); - ComponentB = await B.edit(); - ComponentC = await C.edit(); - ComponentD = await D.edit(); - ComponentX = await X.edit(); - ComponentT = await T.edit(); -} - -async function resetEnv () { - const modId = testDir + 'project1/test.cp.js'; - const mod = module(System, modId); - await mod.unload(); - await resource(testDir).remove(); - await System._livelyModulesTranslationCache.deleteCachedData(modId); -} - -describe('component -> source reconciliation', function () { - beforeEach(async () => { - await prepareEnv(); - }); - - afterEach(async () => { - await resetEnv(); - }); - - it('updates the module source if a components prop changes', async () => { - ComponentA.withMetaDo({ reconcileChanges: true }, () => { - ComponentA.fill = Color.orange; - ComponentA.getSubmorphNamed('some submorph').width = 100; - }); - await ComponentA._changeTracker.onceChangesProcessed(); - const updatedSource = await getSource(); - expect(updatedSource.includes('name: \'A\',\n fill: Color.orange'), 'updates fill in code').to.be.true; - expect(updatedSource.includes('extent: pt(100,50)'), 'updates width in code').to.be.true; - }); - - it('inserts properties in proper order', async () => { - ComponentC.withMetaDo({ reconcileChanges: true }, () => { - ComponentC.layout = new TilingLayout(); - ComponentC.extent = pt(40, 40); - ComponentC.addMorph({ - name: 'foo', fill: Color.red, type: Label, fontColor: Color.green - }); - }); - await ComponentC._changeTracker.onceChangesProcessed(); - const updatedSource = await getSource(); - expect(C.stylePolicy.spec.extent).to.equal(pt(40, 40)); - expect(updatedSource).to.includes(`const C = component({ - name: 'C', - extent: pt(40, 40), - layout: new TilingLayout({}), - fill: Color.grey, - submorphs: [{ - type: Label, - name: 'foo', - fill: Color.red, - fontColor: Color.green - }] -});`); - }); - - it('updates the module if a component prop is set back to its parent value', async () => { - ComponentB.withMetaDo({ reconcileChanges: true }, () => { - ComponentB.get('some submorph').fill = Color.yellow; - }); - await ComponentB._changeTracker.onceChangesProcessed(); - const updatedSource = await getSource(); - expect(updatedSource).includes(`const B = component(A, { - name: 'B' -});`); - }); - - it('updates the imports if we introduce undefined refs', async () => { - ComponentA.withMetaDo({ reconcileChanges: true }, () => { - ComponentA.getSubmorphNamed('some submorph').padding = rect(5, 5, 5, 5); - }); - await ComponentA._changeTracker.onceChangesProcessed(); - const updatedSource = await getSource(); - expect(updatedSource.includes('import { rect } from \'lively.graphics/geometry-2d.js\';'), 'inserts the import').to.be.true; - }); - - it('updates the source if a submorph is added', async () => { - ComponentA.withMetaDo({ reconcileChanges: true }, () => { - ComponentA.addMorph(morph({ - name: 'some new morph', - fill: Color.blue - })); - }); - await ComponentA._changeTracker.onceChangesProcessed(); - const updatedSource = await getSource(); - expect(updatedSource.includes('name: \'some new morph\','), 'inserts a new morph').to.be.true; - }); - - it('updates the source if a part is added', async () => { - ComponentC.withMetaDo({ reconcileChanges: true }, () => { - ComponentC.addMorph(part(B, { - name: 'derived morph', - borderColor: Color.black, - borderWidth: 2 - })); - }); - await ComponentC._changeTracker.onceChangesProcessed(); - const updatedSource = await getSource(); - expect(updatedSource.includes(`submorphs: [part(B, { - name: 'derived morph', - borderColor: Color.black, - borderWidth: 2 - })]`), 'inserts part reference into source code').to.be.true; - }); - - it('correctly respects the order a submorph is inserted at', async () => { - ComponentB.withMetaDo({ reconcileChanges: true }, () => { - ComponentB.addMorph(morph({ - name: 'some new morph', - fill: Color.blue - }), ComponentB.get('some submorph')); - }); - await ComponentB._changeTracker.onceChangesProcessed(); - let updatedSource = await getSource(); - expect(updatedSource.includes(`add({ - name: 'some new morph', - fill: Color.blue - }, 'some submorph')`), 'inserts a new morph before another one').to.be.true; - - ComponentB.withMetaDo({ reconcileChanges: true }, () => { - ComponentB.get('some new morph').bringToFront(); - }); - await ComponentB._changeTracker.onceChangesProcessed(); - updatedSource = await getSource(); - expect(updatedSource.includes(`add({ - name: 'some new morph', - fill: Color.blue - }, 'some submorph')`), 'removes previous add call').not.to.be.true; - expect(updatedSource.includes(`add({ - name: 'some new morph', - fill: Color.blue - })`), 'allows the added component to move to front').to.be.true; - }); - - it('updates the source if a submorph is removed', async () => { - ComponentA.withMetaDo({ reconcileChanges: true }, () => { - ComponentA.getSubmorphNamed('some submorph').remove(); - }); - await ComponentA._changeTracker.onceChangesProcessed(); - let updatedSource = await getSource(); - - expect(updatedSource.includes('type: Text,\n name: \'some submorph\','), 'removes a morph from source').to.be.false; - expect(updatedSource.includes('submorphs: [part(D, { name: \'some ref\' })]'), 'removes the submorph from array').to.be.true; - ComponentA.withMetaDo({ reconcileChanges: true }, () => { - ComponentA.getSubmorphNamed('some ref').remove(); - }); - await ComponentA._changeTracker.onceChangesProcessed(); - updatedSource = await getSource(); - expect(updatedSource.includes('submorphs: []'), 'removes the submorph array').to.be.false; - }); - - it('updates the layouts definitions in response to a morph getting removed', async () => { - ComponentB.withMetaDo({ reconcileChanges: true }, async () => { - ComponentB.layout = new TilingLayout({ - resizePolicies: [ - ['some submorph', { height: 'fill', width: 'fill' }] - ] - }); - }); - await ComponentB._changeTracker.onceChangesProcessed(); - let updatedSource = await getSource(); - expect(updatedSource).includes(`const B = component(A, { - name: 'B', - layout: new TilingLayout({ - resizePolicies: [['some submorph', { - height: 'fill', - width: 'fill' - }]] - }), - submorphs: [{ - name: 'some submorph', - fill: Color.green - }] -});`); - - let removedMorph; - ComponentB.withMetaDo({ reconcileChanges: true }, async () => { - removedMorph = ComponentB.getSubmorphNamed('some submorph').remove(); - }); - await ComponentB._changeTracker.onceChangesProcessed(); - updatedSource = await getSource(); - expect(ComponentB.layout._resizePolicies.has(removedMorph)).to.be.false; - expect(updatedSource).includes(`const B = component(A, { - name: 'B', - layout: new TilingLayout({}), - submorphs: [without('some submorph')] -});`); - }); - - it('updates a part ref if its overridden props change', async () => { - ComponentA.withMetaDo({ reconcileChanges: true }, () => { - ComponentA.getSubmorphNamed('some ref').borderRadius = 10; - }); - await ComponentA._changeTracker.onceChangesProcessed(); - const updatedSource = await getSource(); - expect(updatedSource.includes(`part(D, { - name: 'some ref', - borderRadius: 10 - })`)).to.be.true; - }); - - it('handles changes to multiple components at the same time', async () => { - ComponentA.withMetaDo({ reconcileChanges: true }, () => { - ComponentA.borderWidth = 50; - }); - ComponentB.withMetaDo({ reconcileChanges: true }, () => { - ComponentB.borderRadius = 25; - }); - await ComponentA._changeTracker.onceChangesProcessed(); - const updatedSource = await getSource(); - expect(updatedSource.includes('borderWidth: 50') && updatedSource.includes('borderRadius: 25')).to.be.true; - }); - - it('uncollapses submorph hierarchy if a deeply located submorph is modified', async () => { - ComponentB.withMetaDo({ reconcileChanges: true }, () => { - ComponentB.getSubmorphNamed('a deep morph').fill = Color.blue; - }); - await ComponentB._changeTracker.onceChangesProcessed(); - const updatedSource = await getSource(); - expect(updatedSource).to.include(`const B = component(A, { - name: 'B', - submorphs: [{ - name: 'some submorph', - fill: Color.green - }, { - name: 'some ref', - submorphs: [{ - name: 'a deep morph', - fill: Color.blue - }] - }] -});`); - }); - - it('uncollapses a submorph a the PROPER location', async () => { - ComponentX.withMetaDo({ reconcileChanges: true }, () => { - ComponentX.getSubmorphNamed('some ref').fill = Color.lively; - ComponentX.getSubmorphNamed('some submorph').fill = Color.gray; - }); - await ComponentX._changeTracker.onceChangesProcessed(); - const updatedSource = await getSource(); - expect(updatedSource).to.include(`const X = component(B, { - name: 'X', - submorphs: [{ - name: 'some submorph', - fill: Color.gray - }, { - name: 'some ref', - fill: Color.lively - }] -});`); - }); - - it('scopes submorphs properly by master components', async () => { - ComponentC.withMetaDo({ reconcileChanges: true }, () => { - ComponentC.addMorph({ - type: Label, name: 'some submorph' - }); - }); - - await ComponentC._changeTracker.onceChangesProcessed(); - ComponentA = await A.edit(); - const trap = part(C, { name: 'name trap' }); - ComponentA.withMetaDo({ reconcileChanges: true }, () => { - ComponentA.addMorph(trap); - }); - await ComponentA._changeTracker.onceChangesProcessed(); - trap.withMetaDo({ reconcileChanges: true }, () => { - trap.getSubmorphNamed('some submorph').fill = Color.black; - }); - await ComponentA._changeTracker.onceChangesProcessed(); - const updatedSource = await getSource(); - expect(updatedSource).to.include(`part(C, { - name: 'name trap', - submorphs: [{ - name: 'some submorph', - fill: Color.black - }] - })`); - }); - - it('skips unnessecary properties of morphs', async () => { - ComponentC.addMorph({ - type: Label, name: 'some label', extent: pt(42, 42) - }); - ComponentC.layout = new TilingLayout({ spacing: 5, renderViaCSS: false }); - await ComponentC._changeTracker.onceChangesProcessed(); - const updatedSource = await getSource(); - expect(updatedSource).not.to.include('extent: pt(42, 42)'); - expect(updatedSource).not.to.include('position: pt(5, 5)'); - }); - - describe('source editor integration', () => { - let sourceEditor; - - beforeEach(async () => { - sourceEditor = morph({ type: 'text', textString: initSource, readOnly: false, editorModeName: 'js' }); - await promise.waitFor(1000, () => sourceEditor.editorPlugin); - const testModuleId = testDir + 'project1/test.cp.js'; - sourceEditor.editorPlugin.evalEnvironment.targetModule = testModuleId; - sinon.stub(Reconciliation.prototype, 'getEligibleSourceEditors').callsFake((id) => { - if (id === testModuleId) return [sourceEditor]; - else return []; - }); - }); - - afterEach(() => { - Reconciliation.prototype.getEligibleSourceEditors.restore(); - }); - - it('works properly with associated source editors', async () => { - ComponentA.withMetaDo({ reconcileChanges: true }, () => { - ComponentA.borderWidth = 50; - }); - ComponentB.withMetaDo({ reconcileChanges: true }, () => { - ComponentB.borderRadius = 25; - }); - // changes are immediately propagated to the sourceEditors - expect(sourceEditor.textString).to.include('borderWidth: 50,'); - expect(sourceEditor.textString).to.include('borderRadius: 25,'); - }); - }); - - it('updates a part ref if we add a submorph to it', async () => { - ComponentB.withMetaDo({ reconcileChanges: true }, () => { - ComponentB.addMorph({ - name: 'some new morph', - extent: pt(400, 400), - fill: Color.gray - }); - }); - - await ComponentB._changeTracker.onceChangesProcessed(); - const updatedSource = await getSource(); - expect(updatedSource).to.include(`add({ - name: 'some new morph', - extent: pt(400, 400), - fill: Color.gray - })`); - expect(updatedSource).to.include('import { part, add, component, ComponentDescriptor } from \'lively.morphic/components/core.js\';'); - }); - - it('updates a part ref if we remove a submorph from it', async () => { - ComponentB.withMetaDo({ reconcileChanges: true }, () => { - ComponentB.get('some submorph').remove(); - }); - await ComponentB._changeTracker.onceChangesProcessed(); - const updatedSource = await getSource(); - expect(updatedSource).to.include('submorphs: [without(\'some submorph\')]'); - expect(B.stylePolicy.spec.submorphs[1]).to.eql(without('some submorph'), 'updated style policy object'); - }); - - it('discards empty deeply nested nodes if they are no longer needed', async () => { - let updatedSource = await getSource(); - ComponentB.withMetaDo({ reconcileChanges: true }, () => { - ComponentB.get('a deep morph').addMorph({ - name: 'something superflous', - visible: false - }); - }); - await ComponentB._changeTracker.onceChangesProcessed(); - updatedSource = await getSource(); - expect(updatedSource).to.includes('name: \'something superflous\','); - ComponentB.withMetaDo({ reconcileChanges: true }, () => { - ComponentB.get('something superflous').remove(); - }); - await ComponentB._changeTracker.onceChangesProcessed(); - updatedSource = await getSource(); - expect(updatedSource).to.includes(`{ - name: 'B', - submorphs: [{ - name: 'some submorph', - fill: Color.green - }] -}`); - }); - - it('updates the source AND the spec in case a rename is detected', async () => { - ComponentA.withMetaDo({ reconcileChanges: true }, () => { - ComponentA.get('some ref').name = 'molly'; - }); - await ComponentA._changeTracker.onceChangesProcessed(); - let updatedSource = await getSource(); - expect(updatedSource).to.include('name: \"molly\"'); - expect(A.stylePolicy.spec.submorphs[1].name).to.eql('molly'); - ComponentB.withMetaDo({ reconcileChanges: true }, () => { - ComponentB.addMorph(part(C, { name: 'tbd' })); - }); - await ComponentB._changeTracker.onceChangesProcessed(); - ComponentB.withMetaDo({ reconcileChanges: true }, () => { - ComponentB.get('tbd').name = 'final!'; - }); - await ComponentB._changeTracker.onceChangesProcessed(); - updatedSource = await getSource(); - expect(updatedSource).to.include('name: \"final!\"'); - }); - - it('properly resolves names by path instead of name', async () => { - ComponentC.withMetaDo({ reconcileChanges: true }, () => { - const alice = ComponentC.addMorph(part(D, { name: 'alice' })); - const bob = ComponentC.addMorph(part(D, { name: 'bob' })); - bob.submorphs[0].borderWidth = 40; - alice.submorphs[0].borderRadius = 100; - }); - await ComponentC._changeTracker.onceChangesProcessed(); - const updatedSource = await getSource(); - expect(updatedSource).to.include(`{ - name: 'a deep morph', - borderWidth: 40 - }`); - expect(updatedSource).to.include(`{ - name: 'a deep morph', - borderRadius: 100 - }`); - }); - - it('properly reconciles overridden masters', async () => { - const alice = part(D, { name: 'alice' }); - alice.master.applyConfiguration({ hover: B }); - - ComponentC.withMetaDo({ reconcileChanges: true }, () => { - ComponentC.addMorph(alice); - }); - await ComponentC._changeTracker.onceChangesProcessed(); - const updatedSource = await getSource(); - expect(updatedSource).to.include(`master: { - hover: B - }`); - }); - - it('inserts morphs at the correct position when altering a base def', async () => { - ComponentC.withMetaDo({ reconcileChanges: true }, () => { - const alice = ComponentC.addMorph({ - name: 'alice', - fill: Color.lively - }); - const bob = ComponentC.addMorph({ - name: 'bob', - fill: Color.brown - }, alice); - const foo = ComponentC.addMorph({ - name: 'foo', - fill: Color.purple - }, alice); - foo.remove(); - ComponentC.addMorph(foo, bob); - }); - await ComponentC._changeTracker.onceChangesProcessed(); - const updatedSource = await getSource(); - expect(ComponentC.submorphs.map(m => m.name)).eql(['foo', 'bob', 'alice']); - expect(updatedSource).to.include(`[{ - name: 'foo', - fill: Color.purple - }, { - name: 'bob', - fill: Color.brown - }, { - name: 'alice', - fill: Color.lively - }]`); - }); - - describe('text property reconciliation', () => { - it('reconciles textAndAttributes', async () => { - ComponentD.withMetaDo({ reconcileChanges: true }, () => { - const l = ComponentD.addMorph({ - type: Label, name: 'some label' - }); - l.textAndAttributes = ['Hello World!', null]; - }); - await ComponentD._changeTracker.onceChangesProcessed(); - const updatedSource = await getSource(); - expect(updatedSource).to.include("textAndAttributes: [\'Hello World!\', null]"); - }); - - it('correctly replaces other text attributes, in case they are previously present', async () => { - ComponentT.withMetaDo({ reconcileChanges: true }, () => { - ComponentT.submorphs[0].textString += 'lol'; - ComponentT.submorphs[1].textString += '\nblubber'; - }); - await ComponentD._changeTracker.onceChangesProcessed(); - const updatedSource = await getSource(); - expect(updatedSource).not.to.include('textString: \'yo bro!blubber\''); - expect(updatedSource).not.to.include('value: \'hello worldlol\''); - expect(updatedSource).to.include('textAndAttributes: [\'hello worldlol\', null]'); - expect(updatedSource).to.include('textAndAttributes: [\'yo bro!\\nblubber\', null]'); - }); - - it('correctly reconciles text attributes when deleting trailing parts', async () => { - const m = ComponentT.submorphs[0]; - m.readOnly = false; - ComponentT.withMetaDo({ reconcileChanges: true }, () => { - m.textAndAttributes = ['This is the ', null, 'lively.next', { - fontWeight: '500' - }, ' impressum.', null]; - }); - await ComponentD._changeTracker.onceChangesProcessed(); - ComponentT.withMetaDo({ reconcileChanges: true }, () => { - m.deleteText({ - start: { row: 0, column: m.documentEndPosition.column - 1 }, - end: m.documentEndPosition - }); - }); - await ComponentD._changeTracker.onceChangesProcessed(); - const updatedSource = await getSource(); - expect(updatedSource).to.include(`textAndAttributes: ['This is the ', null, 'lively.next', { - fontWeight: '500' - }, ' impressum', null]`); - }); - - it('properly reconciles embedded morphs', async () => { - ComponentA.withMetaDo({ reconcileChanges: true }, () => { - ComponentA.get('some submorph').addMorph({ - name: 'trolly', - type: 'text', - textAndAttributes: [ - 'Hello World', { fontSize: 20 }, - morph({ - name: 'foo', - fill: Color.blue - }), null, - 'How about a component', { fontWeight: 'bold' }, - part(B, { name: 'bar' }), null - ] - }); - }); - await ComponentA._changeTracker.onceChangesProcessed(); - let updatedSource = await getSource(); - expect(updatedSource).includes(`['Hello World', { - fontSize: 20 - }, morph({ - name: 'foo', - fill: Color.blue - }), null, 'How about a component', { - fontWeight: 'bold' - }, part(B, { - name: 'bar' - }), null]`, 'reconciles added plain morphs'); - }); - - it('properly reconciles settings text and attributes with morphs', async () => { - ComponentB.withMetaDo({ reconcileChanges: true }, () => { - ComponentB.get('some submorph').textAndAttributes = [ - 'Hello World', { fontSize: 20 }, - morph({ - name: 'charlie', - fill: Color.blue - }), null, - 'How about a component', { fontWeight: 'bold' }, - part(C, { name: 'justin' }), null - ]; - }); - await ComponentB._changeTracker.onceChangesProcessed(); - let updatedSource = await getSource(); - expect(updatedSource).includes(`['Hello World', { - fontSize: 20 - }, morph({ - name: 'charlie', - fill: Color.blue - }), null, 'How about a component', { - fontWeight: 'bold' - }, part(C, { - name: 'justin' - }), null]`, 'reconciles embedded morphs if assigned via text and attributes'); - }); - - it('properly reconciles insertion and deletions of newlines', async () => { - const textMorph = ComponentB.get('some submorph'); - textMorph.readOnly = false; - textMorph.execCommand('insertstring', { string: 'hello' }); - textMorph.execCommand('insertstring', { string: '\n' }); - textMorph.execCommand('insertstring', { string: 'r' }); - textMorph.execCommand('insertstring', { string: '\n' }); - textMorph.execCommand('insertstring', { string: 'o' }); - - await ComponentB._changeTracker.onceChangesProcessed(); - let updatedSource = await getSource(); - expect(updatedSource).includes('[\'hello\\nr\\no\', null]', 'newlines are inserted at the correct places in the source'); - - textMorph.execCommand('delete backwards'); - textMorph.execCommand('delete backwards'); - await ComponentB._changeTracker.onceChangesProcessed(); - updatedSource = await getSource(); - expect(updatedSource).includes('[\'hello\\nr\', null]', 'newlines are deleted at the correct places in the source'); - }); - - it('properly reconciles deletion with DEL key', async () => { - const textMorph = ComponentT.get('another greeter'); - textMorph.readOnly = false; - textMorph.cursorPosition = { column: 0, row: 0 }; - textMorph.execCommand('delete'); - await ComponentT._changeTracker.onceChangesProcessed(); - let updatedSource = await getSource(); - expect(updatedSource).includes('\'o bro!\'', 'forward deletion to work'); - }); - }); - - describe('updating derived components', () => { - it('properly propagates structure among derived component definitions', async () => { - // removing a morph should alter the structure within the derived components accordingly - ComponentA.withMetaDo({ reconcileChanges: true }, () => { - ComponentA.get('some submorph').remove(); - }); - await ComponentA._changeTracker.onceChangesProcessed(); - let updatedSource = await getSource(); - expect(updatedSource).includes(`const A = component({ - name: 'A', - fill: Color.red, - extent: pt(100, 100), - submorphs: [part(D, { name: 'some ref' })] -});`, 'removes morph from root def'); - - expect(updatedSource).includes(`const B = component(A, { - name: 'B' -});`, 'removes morph from derived defs'); - expect(B.stylePolicy.lookForMatchingSpec('some submorph')).to.be.null; - }); - - it('preserves derived component alterations if they are reintroduced', async () => { - // removing a morph should alter the structure within the derived components accordingly - let removedMorph; - ComponentA.withMetaDo({ reconcileChanges: true }, () => { - removedMorph = ComponentA.get('some submorph').remove(); - }); - await ComponentA._changeTracker.onceChangesProcessed(); - - // adding the same morph back at another location in the component, should preserve the - // adjustments but at a different location - ComponentA.withMetaDo({ reconcileChanges: true }, () => { - ComponentA.addMorph(removedMorph); - }); - - await ComponentA._changeTracker.onceChangesProcessed(); - let updatedSource = await getSource(); - - expect(updatedSource).includes(`const A = component({ - name: 'A', - fill: Color.red, - extent: pt(100, 100), - submorphs: [part(D, { name: 'some ref' }), { - type: Text, - name: 'some submorph', - extent: pt(50, 50), - fixedWidth: true, - fixedHeight: true, - fill: Color.yellow - }] -});`, 'add morph to root def'); - - expect(updatedSource).includes(`const B = component(A, { - name: 'B', - submorphs: [{ - name: 'some submorph', - fill: Color.green - }] -});`, 'reintroduces the previously removed adjustments'); - }); - - it('resolves name conflicts for morphs that are added to a definition', async () => { - let updatedSource; - ComponentA.withMetaDo({ reconcileChanges: true }, () => { - ComponentA.addMorph(morph({ name: 'robin', fill: Color.cyan })); - ComponentA.addMorph(morph({ name: 'robin', fill: Color.brown })); - }); - await ComponentA._changeTracker.onceChangesProcessed(); - updatedSource = await getSource(); - expect(updatedSource).includes('name: \'robin_1\''); - expect(updatedSource).includes('name: \'robin\''); - ComponentA.withMetaDo({ reconcileChanges: true }, () => { - ComponentA.get('robin').remove(); - ComponentA.get('robin_1').remove(); - }); - await ComponentA._changeTracker.onceChangesProcessed(); - ComponentB.withMetaDo({ reconcileChanges: true }, () => { - ComponentB.addMorph(morph({ name: 'robin', fill: Color.cyan })); - }); - await ComponentB._changeTracker.onceChangesProcessed(); - ComponentB.withMetaDo({ reconcileChanges: true }, () => { - ComponentB.addMorph(morph({ name: 'robin', fill: Color.brown })); - }); - await ComponentB._changeTracker.onceChangesProcessed(); - updatedSource = await getSource(); - expect(updatedSource).includes('name: \'robin_1\''); - expect(updatedSource).includes('name: \'robin\''); - // name collisions (by adding a new morph with a name already existing in the derived components) - // should enforce a renaming of that dropped morph for now. If we run into issues, - // we will introduce a custom tag attribute that allows designers to refer to morphs - // with a fixed custom name that is not constrained by any - ComponentB.withMetaDo({ reconcileChanges: true }, () => { - ComponentB.get('robin').remove(); - ComponentB.get('robin_1').remove(); - ComponentB.addMorph(morph({ name: 'linus', fill: Color.lively })); - ComponentA.addMorph(morph({ name: 'linus', fill: Color.green })); - }); - await ComponentB._changeTracker.onceChangesProcessed(); - updatedSource = await getSource(); - expect(updatedSource).includes(`const B = component(A, { - name: 'B', - submorphs: [{ - name: 'some submorph', - fill: Color.green - }, add({ - name: 'linus', - fill: Color.lively - })] -});`, 'insert the add() call for the new morph'); - - expect(updatedSource).includes(`const A = component({ - name: 'A', - fill: Color.red, - extent: pt(100, 100), - submorphs: [{ - type: Text, - name: 'some submorph', - extent: pt(50, 50), - fixedWidth: true, - fixedHeight: true, - fill: Color.yellow - }, part(D, { name: 'some ref' }), { - name: 'linus_1', - fill: Color.green - }] -});`, 'inserts a renamed morph to avoid name collision'); - - ComponentB.withMetaDo({ reconcileChanges: true }, () => { - ComponentA.addMorph(morph({ name: 'linus', fill: Color.purple })); - }); - await ComponentA._changeTracker.onceChangesProcessed(); - await ComponentB._changeTracker.onceChangesProcessed(); - updatedSource = await getSource(); - - expect(updatedSource).includes(`const A = component({ - name: 'A', - fill: Color.red, - extent: pt(100, 100), - submorphs: [{ - type: Text, - name: 'some submorph', - extent: pt(50, 50), - fixedWidth: true, - fixedHeight: true, - fill: Color.yellow - }, part(D, { name: 'some ref' }), { - name: 'linus_1', - fill: Color.green - }, { - name: 'linus_2', - fill: Color.purple - }] -});`, 'also renames a morph if collision with one of the derived specs is detected'); - }); - - it('renames submorphs inside an introduced submorph hierarchy if nessecary', () => { - let updatedSource, introducedMorph; - ComponentA.withMetaDo({ reconcileChanges: true }, () => { - introducedMorph = ComponentA.addMorph(morph({ - name: 'robin', - fill: Color.cyan, - submorphs: [ - { name: 'some ref' }, { name: 'some submorph' } - ] - })); - }); - expect(introducedMorph.get('some ref_1')).not.to.be.null; - expect(introducedMorph.get('some submorph_1')).not.to.be.null; - }); - - it('renames submorphs that are added to inline policies so that they do no conflict with the inline policy scope', () => { - let updatedSource, introducedMorph; - ComponentA.withMetaDo({ reconcileChanges: true }, () => { - introducedMorph = ComponentA.addMorph(part(A, { - submorphs: [add({ - name: 'some ref' - })] - })); - }); - expect(introducedMorph.get('some ref_1')).not.to.be.null; - }); - - it('reintroduces altered versions if the morph has been tempered with between removal and eintroduction', async () => { - // removing a morph should alter the structure within the derived components accordingly - let removedMorph; - ComponentB.withMetaDo({ reconcileChanges: true }, () => { - ComponentB.get('a deep morph').fill = Color.lively; - }); - await ComponentB._changeTracker.onceChangesProcessed(); - let updatedSource = await getSource(); - ComponentA.withMetaDo({ reconcileChanges: true }, () => { - removedMorph = ComponentA.get('some ref').remove(); - }); - await ComponentA._changeTracker.onceChangesProcessed(); - // clear the morph - ComponentA.withMetaDo({ reconcileChanges: true }, () => { - removedMorph.submorphs[0].remove(); - }); - updatedSource = await getSource(); - - // adding the same morph back at another location in the component, should preserve the - // adjustments but *DROP* the ajustments that have been applied to the now removed morph - ComponentA.withMetaDo({ reconcileChanges: true }, () => { - ComponentA.addMorph(removedMorph); - }); - - await ComponentA._changeTracker.onceChangesProcessed(); - updatedSource = await getSource(); - - expect(updatedSource).includes(`const B = component(A, { - name: 'B', - submorphs: [{ - name: 'some submorph', - fill: Color.green - }] -});`, 'does not mention the newly introduced submorph'); - - expect(updatedSource).includes(`const A = component({ - name: 'A', - fill: Color.red, - extent: pt(100, 100), - submorphs: [{ - type: Text, - name: 'some submorph', - extent: pt(50, 50), - fixedWidth: true, - fixedHeight: true, - fill: Color.yellow - }, part(D, { - name: 'some ref', - submorphs: [without('a deep morph')] - })] -});`, 'inserts adjustments in the reintroduced code'); - }); - - it('reflect the propagated changes in any of the open editable components', async () => { - ComponentA.withMetaDo({ reconcileChanges: true }, () => { - ComponentA.get('some submorph').moveBy(pt(20, 20)); - }); - await ComponentA._changeTracker.onceChangesProcessed(); - expect(ComponentB.get('some submorph').position).equals(ComponentA.get('some submorph').position); - expect(ComponentX.get('some submorph').position).equals(ComponentA.get('some submorph').position); - - let removedMorph; - ComponentA.withMetaDo({ reconcileChanges: true }, () => { - removedMorph = ComponentA.get('some submorph').remove(); - }); - await ComponentA._changeTracker.onceChangesProcessed(); - expect(ComponentB.get('some submorph')).to.be.null; - expect(ComponentX.get('some submorph')).to.be.null; - - ComponentA.withMetaDo({ reconcileChanges: true }, () => { - ComponentA.addMorph(removedMorph); - }); - await ComponentA._changeTracker.onceChangesProcessed(); - expect(ComponentB.get('some submorph')).not.to.be.null; - expect(ComponentB.get('some submorph').owner).to.equal(ComponentB); - expect(ComponentX.get('some submorph')).not.to.be.null; - expect(ComponentX.get('some submorph').owner).to.equal(ComponentX); - - ComponentA.withMetaDo({ reconcileChanges: true }, () => { - ComponentA.addMorph(morph({ name: 'clippy', type: 'label', textString: 'It looks like your writing a letter!' })); - }); - await ComponentA._changeTracker.onceChangesProcessed(); - expect(ComponentB.get('clippy')).not.to.be.null; - expect(ComponentB.submorphs.length).to.equal(3); - expect(ComponentX.get('clippy')).not.to.be.null; - expect(ComponentX.submorphs.length).to.equal(3); - expect(A.stylePolicy.getSubSpecFor('clippy')).not.to.be.null; - expect(B.stylePolicy.getSubSpecFor('clippy')).not.to.be.null; - expect(X.stylePolicy.getSubSpecFor('clippy')).not.to.be.null; - }); - }); -}); diff --git a/lively.ide/tests/components/runtime-node-serializer-test.js b/lively.ide/tests/components/runtime-node-serializer-test.js new file mode 100644 index 0000000000..65786139d0 --- /dev/null +++ b/lively.ide/tests/components/runtime-node-serializer-test.js @@ -0,0 +1,646 @@ +/* global describe, it */ +import { expect } from 'mocha-es6'; +import { parseComponentSource } from '../../components/reconciliation/source-adapter.js'; +import { + ComponentNodeProvenanceKind, + sourceComponentReference +} from '../../components/reconciliation/component-document.js'; +import { + RuntimeNodeSerializationDiagnosticKind, + serializeRuntimeComponentNode +} from '../../components/reconciliation/runtime-node-serializer.js'; + +const moduleId = 'local://runtime-node-serializer/component.cp.js'; +const componentId = `${moduleId}#Example`; + +function document () { + return parseComponentSource({ + source: `const Example = component({ name: 'root' });`, + moduleId, + exportName: 'Example', + componentId + }).document; +} + +describe('projectional runtime node serializer', () => { + it('serializes plain morph specs into semantic nodes with import requirements', () => { + const fill = { + __serialize__: () => ({ + __expr__: 'Color.green', + bindings: { 'lively.graphics': ['Color'] } + }) + }; + const componentDocument = document(); + const serialized = serializeRuntimeComponentNode({ + document: componentDocument, + parentId: componentDocument.root.id, + index: 0, + morph: { + spec: () => ({ + name: 'introduced', + fill, + submorphs: [{ name: 'nested', opacity: 0.5, submorphs: [] }] + }) + } + }); + + expect(serialized.supported).to.be.true; + expect(serialized.node.id).equals(`${componentId}:node:0`); + expect(serialized.node.children[0].id).equals(`${componentId}:node:0.0`); + expect(serialized.node.properties.fill.expression).equals('Color.green'); + expect(serialized.requiredBindings[0]).containSubset({ + moduleId: 'lively.graphics', + imported: 'Color', + local: 'Color' + }); + }); + + it('preserves source-located runtime types and their import requirements', () => { + class TypedMorph {} + TypedMorph[Symbol.for('lively-module-meta')] = { + package: { name: 'local://widgets' }, + pathInPackage: 'typed-morph.js' + }; + const componentDocument = document(); + const serialized = serializeRuntimeComponentNode({ + document: componentDocument, + parentId: componentDocument.root.id, + index: 0, + morph: { + spec: () => ({ + name: 'typed', + type: TypedMorph, + submorphs: [] + }) + } + }); + + expect(serialized.supported).to.be.true; + expect(serialized.node.typeExpression).equals('TypedMorph'); + expect(serialized.requiredBindings[0]).containSubset({ + moduleId: 'local://widgets/typed-morph.js', + imported: 'TypedMorph', + local: 'TypedMorph' + }); + }); + + it('rejects source-less runtime types before producing a semantic node', () => { + class SourceLessMorph {} + SourceLessMorph[Symbol.for('lively-module-meta')] = null; + const componentDocument = document(); + const serialized = serializeRuntimeComponentNode({ + document: componentDocument, + parentId: componentDocument.root.id, + index: 0, + morph: { + spec: () => ({ + name: 'unsupported', + type: SourceLessMorph, + submorphs: [] + }) + } + }); + + expect(serialized.supported).to.be.false; + expect(serialized.node).equals(null); + expect(serialized.diagnostics[0].kind) + .equals(RuntimeNodeSerializationDiagnosticKind.UNSUPPORTED_TYPE); + }); + + it('preserves source component provenance for introduced parts', () => { + class DerivedMorph {} + const componentMeta = { + exportedName: 'Button', + moduleId: 'local://widgets/button.cp.js', + path: [] + }; + const morph = { + master: { + _isOverridden: true, + parent: { [Symbol.for('lively-module-meta')]: componentMeta }, + _originalSpec: { name: 'button', fill: 'red' }, + spec: { + name: 'button', + fill: 'red', + opacity: 0.5, + submorphs: [{ name: 'inherited label', opacity: 0.7 }] + } + }, + submorphs: [{ name: 'inherited label' }], + spec: () => ({ + name: 'button', + type: DerivedMorph, + fill: 'red', + opacity: 0.5, + master: { cannotSerialize: () => {} }, + submorphs: [{ name: 'inherited label', opacity: 0.7, submorphs: [] }] + }) + }; + const componentDocument = document(); + const serialized = serializeRuntimeComponentNode({ + document: componentDocument, + parentId: componentDocument.root.id, + index: 0, + morph + }); + + expect(serialized.supported).to.be.true; + expect(serialized.node.partComponent.expression).equals('Button'); + expect(serialized.node.typeExpression).equals(null); + expect(serialized.node.properties.fill.value).equals('red'); + expect(serialized.node.properties).not.haveOwnProperty('opacity'); + expect(serialized.node.properties).not.haveOwnProperty('master'); + expect(serialized.node.children).to.have.length(1); + expect(serialized.node.children[0].name).equals('inherited label'); + expect(serialized.node.children[0].properties).deep.equals({}); + expect(serialized.requiredBindings).containSubset([ + { + moduleId: 'local://widgets/button.cp.js', + imported: 'Button', + local: 'Button' + }, + { + moduleId: 'lively.morphic', + imported: 'part', + local: 'part' + } + ]); + }); + + it('includes resolved inherited children in an introduced part document', () => { + const componentMeta = { + exportedName: 'Button', + moduleId: 'local://widgets/button.cp.js', + path: [] + }; + const resolvedPart = parseComponentSource({ + source: `const Button = component({ + name: 'button', + submorphs: [{ name: 'label' }] +});`, + moduleId: componentMeta.moduleId, + exportName: componentMeta.exportedName, + componentId: `${componentMeta.moduleId}#${componentMeta.exportedName}` + }).document; + const componentDocument = parseComponentSource({ + source: `const Example = component({ name: 'root' });`, + moduleId, + exportName: 'Example', + componentId, + resolveComponentDocument: ({ expression }) => + expression === 'Button' ? resolvedPart : null + }).document; + const serialized = serializeRuntimeComponentNode({ + document: componentDocument, + parentId: componentDocument.root.id, + index: 0, + morph: { + master: { + parent: { [Symbol.for('lively-module-meta')]: componentMeta }, + _originalSpec: { name: 'button' } + }, + submorphs: [{ name: 'label' }], + spec: () => ({ + name: 'button', + submorphs: [{ name: 'label', submorphs: [] }] + }) + } + }); + + expect(serialized.supported).to.be.true; + expect(serialized.node.children.map(({ name }) => name)) + .deep.equals(['label']); + expect(serialized.node.children[0].provenance.kind) + .equals(ComponentNodeProvenanceKind.INHERITED); + }); + + it('materializes policy children before a newly introduced part is attached', () => { + const componentMeta = { + exportedName: 'Button', + moduleId: 'local://widgets/button.cp.js', + path: [] + }; + const serialized = serializeRuntimeComponentNode({ + document: document(), + parentId: document().root.id, + index: 0, + materializePartSubtree: true, + morph: { + master: { + parent: { + [Symbol.for('lively-module-meta')]: componentMeta, + spec: { submorphs: [{ name: 'label', opacity: 0.5 }] } + }, + _originalSpec: { name: 'button' } + }, + submorphs: [], + spec: () => ({ name: 'button', submorphs: [] }) + } + }); + + expect(serialized.supported).to.be.true; + expect(serialized.node.children.map(({ name }) => name)) + .deep.equals(['label']); + }); + + it('preserves nested add commands in overridden part specs', () => { + const componentMeta = { + exportedName: 'Button', + moduleId: 'local://widgets/button.cp.js', + path: [] + }; + const addedChild = { + name: 'badge', + spec: () => ({ name: 'badge', fill: 'red', submorphs: [] }) + }; + const morph = { + master: { + _isOverridden: true, + parent: { [Symbol.for('lively-module-meta')]: componentMeta }, + _originalSpec: { + name: 'button', + submorphs: [{ + COMMAND: 'add', + props: { name: 'badge', fill: 'red', __wasAddedToDerived__: true }, + before: null + }] + } + }, + submorphs: [addedChild], + spec: () => ({ name: 'button', submorphs: [{ name: 'badge', fill: 'red' }] }) + }; + const componentDocument = document(); + const serialized = serializeRuntimeComponentNode({ + document: componentDocument, + parentId: componentDocument.root.id, + index: 0, + morph + }); + + expect(serialized.supported).to.be.true; + expect(serialized.node.children).to.have.length(1); + expect(serialized.node.children[0].name).equals('badge'); + expect(serialized.node.children[0].provenance.kind) + .equals(ComponentNodeProvenanceKind.ADDED); + expect(serialized.node.children[0].properties) + .not.haveOwnProperty('__wasAddedToDerived__'); + expect(serialized.requiredBindings).containSubset([{ + moduleId: 'lively.morphic', + imported: 'add', + local: 'add' + }]); + }); + + it('anchors an unqualified nested add before its following inherited sibling', () => { + const componentMeta = { + exportedName: 'Button', + moduleId: 'local://widgets/button.cp.js', + path: [] + }; + const addedChild = { + name: 'badge', + spec: () => ({ name: 'badge', fill: 'red', submorphs: [] }) + }; + const inheritedChild = { + name: 'label', + spec: () => ({ name: 'label', submorphs: [] }) + }; + const componentDocument = document(); + const serialized = serializeRuntimeComponentNode({ + document: componentDocument, + parentId: componentDocument.root.id, + index: 0, + morph: { + master: { + _isOverridden: true, + parent: { [Symbol.for('lively-module-meta')]: componentMeta }, + _originalSpec: { + name: 'button', + submorphs: [{ + COMMAND: 'add', + props: { name: 'badge', fill: 'red', __wasAddedToDerived__: true }, + before: null + }, { name: 'label' }] + } + }, + submorphs: [addedChild, inheritedChild], + spec: () => ({ + name: 'button', + submorphs: [ + { name: 'badge', fill: 'red' }, + { name: 'label' } + ] + }) + } + }); + + expect(serialized.supported).to.be.true; + expect(serialized.node.children.map(({ name }) => name)) + .deep.equals(['badge', 'label']); + expect(serialized.node.children[0].provenance.beforeId) + .equals(serialized.node.children[1].id); + }); + + it('marks nested policy specs as inherited overrides when the part resolves', () => { + const partMeta = { + exportedName: 'Base', + moduleId: 'local://widgets/base.cp.js', + path: [] + }; + const nestedPolicy = { + isPolicy: true, + _originalSpec: { name: 'nested part', opacity: 0.5 }, + parent: { + [Symbol.for('lively-module-meta')]: { + exportedName: 'Leaf', + moduleId: 'local://widgets/leaf.cp.js', + path: [] + } + } + }; + const resolvedPart = document(); + const componentDocument = parseComponentSource({ + source: `const Example = component({ name: 'root' });`, + moduleId, + exportName: 'Example', + componentId, + resolveComponentDocument: ({ expression }) => + expression === 'Base' ? resolvedPart : null + }).document; + const serialized = serializeRuntimeComponentNode({ + document: componentDocument, + parentId: componentDocument.root.id, + index: 0, + morph: { + master: { + parent: { [Symbol.for('lively-module-meta')]: partMeta }, + _originalSpec: { + name: 'base part', + submorphs: [nestedPolicy] + } + }, + submorphs: [{ + name: 'nested part', + spec: () => ({ name: 'nested part', opacity: 0.5, submorphs: [] }) + }], + spec: () => ({ + name: 'base part', + submorphs: [{ name: 'nested part', opacity: 0.5, submorphs: [] }] + }) + } + }); + + expect(serialized.supported).to.be.true; + expect(serialized.node.children[0].provenance.kind) + .equals(ComponentNodeProvenanceKind.INHERITED); + expect(serialized.node.children[0].provenance.hasLocalOverrides).to.be.true; + expect(serialized.node.children[0].partComponent).equals(null); + }); + + it('uses runtime child order when materializing a resolved part subtree', () => { + const partMeta = { + exportedName: 'Base', + moduleId: 'local://widgets/base.cp.js', + path: [] + }; + const resolvedPart = document(); + const componentDocument = parseComponentSource({ + source: `const Example = component({ name: 'root' });`, + moduleId, + exportName: 'Example', + componentId, + resolveComponentDocument: ({ expression }) => + expression === 'Base' ? resolvedPart : null + }).document; + const localAddition = { + COMMAND: 'add', + props: { name: 'local addition', opacity: 0.5 }, + before: 'base child' + }; + const runtimeAddition = { + name: 'local addition', + spec: () => ({ name: 'local addition', opacity: 0.5, submorphs: [] }) + }; + const runtimeBaseChild = { + name: 'base child', + spec: () => ({ name: 'base child', submorphs: [] }) + }; + const serialized = serializeRuntimeComponentNode({ + document: componentDocument, + parentId: componentDocument.root.id, + index: 0, + materializePartSubtree: true, + morph: { + master: { + parent: { [Symbol.for('lively-module-meta')]: partMeta }, + _originalSpec: { + name: 'base part', + submorphs: [{ name: 'base child' }, localAddition] + } + }, + submorphs: [runtimeAddition, runtimeBaseChild], + spec: () => ({ + name: 'base part', + submorphs: [ + { name: 'local addition', opacity: 0.5, submorphs: [] }, + { name: 'base child', submorphs: [] } + ] + }) + } + }); + + expect(serialized.supported).to.be.true; + expect(serialized.node.children.map(({ name }) => name)) + .deep.equals(['local addition', 'base child']); + expect(serialized.node.children[0].provenance.kind) + .equals(ComponentNodeProvenanceKind.ADDED); + expect(serialized.node.children[1].provenance.kind) + .equals(ComponentNodeProvenanceKind.INHERITED); + }); + + it('uses a semantic part fallback for inherited sub-policies', () => { + const runtimeMaster = { + parent: { + [Symbol.for('lively-module-meta')]: { + exportedName: 'Container', + moduleId: 'local://widgets/container.cp.js', + path: ['nested part'] + } + } + }; + runtimeMaster.self = runtimeMaster; + const componentDocument = document(); + const serialized = serializeRuntimeComponentNode({ + document: componentDocument, + parentId: componentDocument.root.id, + index: 0, + partComponent: sourceComponentReference('Leaf'), + morph: { + master: runtimeMaster, + spec: () => ({ name: 'nested part', master: runtimeMaster, submorphs: [] }) + } + }); + + expect(serialized.supported).to.be.true; + expect(serialized.node.partComponent.expression).equals('Leaf'); + expect(serialized.node.properties).not.haveOwnProperty('master'); + }); + + it('recognizes the root auto policy behind an inherited part applicator', () => { + const leafPolicy = { + [Symbol.for('lively-module-meta')]: { + exportedName: 'Leaf', + moduleId: 'local://widgets/leaf.cp.js', + path: [] + } + }; + const runtimeMaster = { + _autoMaster: leafPolicy, + parent: { + [Symbol.for('lively-module-meta')]: { + exportedName: 'Container', + moduleId: 'local://widgets/container.cp.js', + path: ['nested part'] + } + }, + _originalSpec: { name: 'nested part' } + }; + const componentDocument = document(); + const serialized = serializeRuntimeComponentNode({ + document: componentDocument, + parentId: componentDocument.root.id, + index: 0, + morph: { + master: runtimeMaster, + spec: () => ({ name: 'nested part', master: runtimeMaster, submorphs: [] }) + } + }); + + expect(serialized.supported).to.be.true; + expect(serialized.node.partComponent.expression).equals('Leaf'); + expect(serialized.node.properties).not.haveOwnProperty('master'); + }); + + it('recognizes the component parent behind an inherited nested policy', () => { + const leafPolicy = { + [Symbol.for('lively-module-meta')]: { + exportedName: 'Leaf', + moduleId: 'local://widgets/leaf.cp.js', + path: [] + } + }; + const nestedPolicy = { + _parent: leafPolicy, + get parent () { return this._parent; }, + [Symbol.for('lively-module-meta')]: { + exportedName: 'Container', + moduleId: 'local://widgets/container.cp.js', + path: ['nested part'] + } + }; + const runtimeMaster = { + parent: nestedPolicy, + _originalSpec: { name: 'nested part' } + }; + const componentDocument = document(); + const serialized = serializeRuntimeComponentNode({ + document: componentDocument, + parentId: componentDocument.root.id, + index: 0, + morph: { + master: runtimeMaster, + spec: () => ({ name: 'nested part', master: runtimeMaster, submorphs: [] }) + } + }); + + expect(serialized.supported).to.be.true; + expect(serialized.node.partComponent.expression).equals('Leaf'); + expect(serialized.node.properties).not.haveOwnProperty('master'); + }); + + it('prefers a direct part policy over an applicator auto policy', () => { + const policyMeta = (exportedName, moduleId) => ({ + exportedName, moduleId, path: [] + }); + const componentDocument = document(); + const serialized = serializeRuntimeComponentNode({ + document: componentDocument, + parentId: componentDocument.root.id, + index: 0, + morph: { + master: { + _autoMaster: { + [Symbol.for('lively-module-meta')]: policyMeta( + 'WrongComponent', 'local://widgets/wrong.cp.js') + }, + parent: { + [Symbol.for('lively-module-meta')]: policyMeta( + 'ExpectedComponent', 'local://widgets/expected.cp.js') + }, + _originalSpec: { name: 'introduced part' } + }, + spec: () => ({ name: 'introduced part', submorphs: [] }) + } + }); + + expect(serialized.supported).to.be.true; + expect(serialized.node.partComponent.expression).equals('ExpectedComponent'); + expect(serialized.requiredBindings).containSubset([{ + moduleId: 'local://widgets/expected.cp.js', + imported: 'ExpectedComponent', + local: 'ExpectedComponent' + }]); + expect(serialized.requiredBindings.some(({ imported }) => + imported === 'WrongComponent')).to.be.false; + }); + + it('allocates a free identity when inserting at an occupied source index', () => { + const parsed = parseComponentSource({ + source: `const Example = component({ + name: 'root', + submorphs: [{ name: 'existing' }] +});`, + moduleId, + exportName: 'Example', + componentId + }).document; + const serialized = serializeRuntimeComponentNode({ + document: parsed, + parentId: parsed.root.id, + index: 0, + morph: { + spec: () => ({ name: 'introduced', submorphs: [] }) + } + }); + + expect(serialized.supported).to.be.true; + expect(serialized.node.id).equals(`${componentId}:node:1`); + expect(serialized.node.id).not.equals(parsed.root.children[0].id); + }); + + it('allocates a stable sibling name and reports the required runtime rename', () => { + const parsed = parseComponentSource({ + source: `const Example = component({ + name: 'root', + submorphs: [{ name: 'duplicate' }] +});`, + moduleId, + exportName: 'Example', + componentId + }).document; + const serialized = serializeRuntimeComponentNode({ + document: parsed, + parentId: parsed.root.id, + index: 1, + morph: { + spec: () => ({ name: 'duplicate', submorphs: [] }) + } + }); + + expect(serialized.supported).to.be.true; + expect(serialized.node.name).equals('duplicate_1'); + expect(serialized.runtimeRename).deep.equals({ + before: 'duplicate', after: 'duplicate_1' + }); + }); +}); diff --git a/lively.ide/tests/components/runtime-projector-test.js b/lively.ide/tests/components/runtime-projector-test.js new file mode 100644 index 0000000000..e7d2982304 --- /dev/null +++ b/lively.ide/tests/components/runtime-projector-test.js @@ -0,0 +1,681 @@ +/* global describe, it */ +import { expect } from 'mocha-es6'; +import { + ComponentDocument, + ComponentNode, + explicitProperty, + inheritedNodeProvenance, + localNodeProvenance, + opaqueProperty +} from '../../components/reconciliation/component-document.js'; +import { + ClearPropertyOverride, + ComponentTextEditKind, + EditText, + IntroduceNode, + MoveNode, + RemoveNode, + RenameNode, + RestoreInheritedNode, + SetOpaqueProperty, + SetMaster, + SetProperty, + SuppressInheritedNode +} from '../../components/reconciliation/commands.js'; +import { reduceComponent } from '../../components/reconciliation/reducer.js'; +import { + ComponentRuntimeProjectionDiagnosticKind, + projectComponentRuntime +} from '../../components/reconciliation/runtime-projector.js'; +import { + DerivedRuntimeStructureProjectionKind, + projectCachedDerivedRuntimeStructure +} from '../../components/reconciliation/derived-runtime-projector.js'; + +function documentWith (properties = {}) { + return new ComponentDocument({ + componentId: 'component', + moduleId: 'local://component.js', + exportName: 'Component', + root: new ComponentNode({ + id: 'root', + name: 'root', + provenance: localNodeProvenance(), + properties + }) + }); +} + +function reduce (document, commandFactory, spec) { + return reduceComponent(document, commandFactory({ + componentId: document.componentId, + expectedRevision: document.revision, + nodeId: document.root.id, + ...spec + })); +} + +function project (document, reduction, options = {}) { + return projectComponentRuntime({ + beforeDocument: document, + reduction, + changeSetId: 'runtime-projection', + resolveRuntimeTargetId: () => 'runtime-root', + ...options + }); +} + +function derivedStructureDocument ({ + moved = false, + removed = false, + targetHasOverrides = false +} = {}) { + const parentNodeProvenance = (hasLocalOverrides = false) => inheritedNodeProvenance({ + suppressed: false, + hasLocalOverrides + }); + const target = removed + ? [] + : [new ComponentNode({ + id: 'target', + name: 'target', + provenance: parentNodeProvenance(targetHasOverrides) + })]; + const existing = new ComponentNode({ + id: 'existing', name: 'existing', provenance: parentNodeProvenance() + }); + return new ComponentDocument({ + componentId: 'derived', + moduleId: 'local://derived-projection/derived.cp.js', + exportName: 'Derived', + parentComponent: { kind: 'source-expression', expression: 'Parent' }, + root: new ComponentNode({ + id: 'derived-root', name: 'derived', provenance: localNodeProvenance(), + children: [ + new ComponentNode({ + id: 'left', name: 'left', provenance: parentNodeProvenance(), + children: moved ? [] : target + }), + new ComponentNode({ + id: 'right', name: 'right', provenance: parentNodeProvenance(), + children: moved ? [existing, ...target] : [existing] + }) + ] + }) + }); +} + +function derivedRuntimeStructure () { + const root = { id: 'derived-runtime-root', name: 'derived', owner: null }; + const left = { id: 'derived-runtime-left', name: 'left', owner: root }; + const right = { id: 'derived-runtime-right', name: 'right', owner: root }; + const target = { id: 'derived-runtime-target', name: 'target', owner: left }; + const existing = { id: 'derived-runtime-existing', name: 'existing', owner: right }; + root.submorphs = [left, right]; + left.submorphs = [target]; + right.submorphs = [existing]; + return { root, left, right, target }; +} + +function derivedComponentPlan (beforeDocument, afterDocument, runtime) { + return { + dependant: { _cachedComponent: runtime.root }, + moduleId: beforeDocument.moduleId, + exportName: beforeDocument.exportName, + projection: { beforeDocument, document: afterDocument } + }; +} + +describe('projectional component runtime projector', () => { + it('prepares exact reversible property change sets without applying them', () => { + const document = documentWith({ fill: explicitProperty('red') }); + const reduction = reduce(document, SetProperty, { property: 'fill', value: 'green' }); + const projection = project(document, reduction); + const runtime = { id: 'runtime-root', fill: 'red' }; + const context = { + resolveMorph: id => id === runtime.id ? runtime : null, + setMorphProperty: (morph, property, value) => { morph[property] = value; } + }; + + expect(projection.supported).to.be.true; + expect(projection.changeSet.origin).equals('runtime-projection'); + expect(projection.changeSet.undoable).to.be.false; + expect(projection.changeSet.operations[0]).containSubset({ + targetId: runtime.id, + property: 'fill', + before: 'red', + after: 'green' + }); + expect(runtime.fill).equals('red'); + projection.changeSet.apply(context); + expect(runtime.fill).equals('green'); + projection.inverseChangeSet.apply(context); + expect(runtime.fill).equals('red'); + }); + + it('snapshots resolved runtime values for opaque expressions', () => { + const beforeValue = { color: 'red' }; + const afterValue = { color: 'green' }; + const document = documentWith({ fill: opaqueProperty('Color.red') }); + const reduction = reduce(document, SetOpaqueProperty, { + property: 'fill', + expression: 'Color.green' + }); + const projection = project(document, reduction, { + resolveRuntimeValue: ({ phase }) => ({ + available: true, + value: phase === 'before' ? beforeValue : afterValue + }) + }); + + expect(projection.supported).to.be.true; + const operation = projection.changeSet.operations[0]; + const inverse = projection.inverseChangeSet.operations[0]; + expect(operation.before).deep.equals(beforeValue); + expect(operation.after).deep.equals(afterValue); + expect(operation.before).not.equals(beforeValue); + expect(operation.after).not.equals(afterValue); + expect(inverse.before).deep.equals(afterValue); + expect(inverse.after).deep.equals(beforeValue); + + beforeValue.color = 'mutated-before'; + afterValue.color = 'mutated-after'; + expect(operation.before).deep.equals({ color: 'red' }); + expect(operation.after).deep.equals({ color: 'green' }); + + const runtime = { id: 'runtime-root', fill: { color: 'red' } }; + const context = { resolveMorph: id => id === runtime.id ? runtime : null }; + projection.changeSet.apply(context); + expect(runtime.fill).deep.equals({ color: 'green' }); + expect(runtime.fill).not.equals(operation.after); + }); + + it('projects semantic text replacements as reversible morphic property changes', () => { + const before = ['before', null]; + const after = ['after', { fontWeight: 'bold' }]; + const document = documentWith({ textAndAttributes: explicitProperty(before) }); + const reduction = reduce(document, EditText, { + operation: { kind: ComponentTextEditKind.REPLACE_ALL, before, after } + }); + const projection = project(document, reduction); + + expect(projection.supported).to.be.true; + expect(projection.changeSet.operations[0]).containSubset({ + property: 'textAndAttributes', + before, + after + }); + }); + + it('projects master changes through the same reversible runtime boundary', () => { + const before = { mode: 'base' }; + const after = { mode: 'hover' }; + const document = documentWith({ master: explicitProperty(before) }); + const reduction = reduce(document, SetMaster, { value: after }); + const projection = project(document, reduction); + + expect(projection.supported).to.be.true; + expect(projection.changeSet.operations[0]).containSubset({ + property: 'master', + before, + after + }); + }); + + it('requires an effective runtime value when clearing an override', () => { + const document = documentWith({ opacity: explicitProperty(0.5) }); + const reduction = reduce(document, ClearPropertyOverride, { property: 'opacity' }); + const unsupported = project(document, reduction); + const supported = project(document, reduction, { + resolveRuntimeValue: ({ phase }) => ({ + available: true, + value: phase === 'before' ? 0.5 : 1 + }) + }); + + expect(unsupported.supported).to.be.false; + expect(unsupported.diagnostics[0].kind) + .equals(ComponentRuntimeProjectionDiagnosticKind.RUNTIME_VALUE_UNAVAILABLE); + expect(supported.changeSet.operations[0].after).equals(1); + }); + + it('projects rename deltas without runtime value evaluation', () => { + const document = documentWith(); + const reduction = reduce(document, RenameNode, { name: 'renamed' }); + const projection = project(document, reduction); + + expect(projection.changeSet.operations[0]).containSubset({ + property: 'name', + before: 'root', + after: 'renamed' + }); + }); + + it('projects owner-layout synchronization as an exact rename companion', () => { + const document = documentWith(); + const reduction = reduce(document, RenameNode, { name: 'renamed' }); + const beforeLayout = Object.create({ isLayout: true }); + const afterLayout = Object.create({ isLayout: true }); + const projection = project(document, reduction, { + resolveRuntimeLayout: () => ({ + ownerId: 'runtime-owner', + before: beforeLayout, + after: afterLayout, + applyWhenAdopting: true + }) + }); + + expect(projection.supported).to.be.true; + expect(projection.changeSet.operations).length(2); + expect(projection.changeSet.operations[0]).containSubset({ + targetId: 'runtime-owner', + property: 'layout', + before: beforeLayout, + after: afterLayout, + metadata: { applyWhenAdopting: true } + }); + expect(projection.inverseChangeSet.operations[1]).containSubset({ + property: 'layout', + before: afterLayout, + after: beforeLayout + }); + }); + + it('projects node removal as an exact reversible attachment change', () => { + const child = new ComponentNode({ + id: 'child', + name: 'child', + provenance: localNodeProvenance() + }); + const document = new ComponentDocument({ + componentId: 'component', + moduleId: 'local://component.js', + exportName: 'Component', + root: new ComponentNode({ + id: 'root', + name: 'root', + provenance: localNodeProvenance(), + children: [child] + }) + }); + const reduction = reduceComponent(document, RemoveNode({ + componentId: document.componentId, + expectedRevision: document.revision, + nodeId: child.id + })); + const projection = projectComponentRuntime({ + beforeDocument: document, + reduction, + changeSetId: 'runtime-removal', + resolveRuntimeTargetId: id => id === child.id + ? 'runtime-child' + : id === document.root.id ? 'runtime-root' : null + }); + const root = { id: 'runtime-root', submorphs: [] }; + const runtimeChild = { id: 'runtime-child', owner: root }; + root.submorphs.push(runtimeChild); + const resolveMorph = id => id === root.id + ? root + : id === runtimeChild.id ? runtimeChild : null; + const context = { + resolveMorph, + validateMoveMorph: (morph, from) => { + expect(morph.owner?.id || null).equals(from.ownerId || null); + }, + moveMorph: (morph, from, to) => { + if (from.ownerId) { + resolveMorph(from.ownerId).submorphs.splice(from.index, 1); + morph.owner = null; + } + if (to.ownerId) { + const owner = resolveMorph(to.ownerId); + owner.submorphs.splice(to.index, 0, morph); + morph.owner = owner; + } + } + }; + + expect(projection.supported).to.be.true; + expect(projection.changeSet.operations[0]).containSubset({ + morphId: runtimeChild.id, + from: { ownerId: root.id, index: 0 } + }); + projection.changeSet.apply(context); + expect(root.submorphs).deep.equals([]); + expect(runtimeChild.owner).equals(null); + projection.inverseChangeSet.apply(context); + expect(root.submorphs).deep.equals([runtimeChild]); + expect(runtimeChild.owner).equals(root); + }); + + it('projects append-only introduction as an exact reversible attachment change', () => { + const document = documentWith(); + const child = new ComponentNode({ + id: 'child', + name: 'child', + provenance: localNodeProvenance() + }); + const reduction = reduceComponent(document, IntroduceNode({ + componentId: document.componentId, + expectedRevision: document.revision, + parentId: document.root.id, + node: child, + beforeId: null + })); + const projection = projectComponentRuntime({ + beforeDocument: document, + reduction, + changeSetId: 'runtime-introduction', + resolveRuntimeTargetId: id => id === child.id + ? 'runtime-child' + : id === document.root.id ? 'runtime-root' : null + }); + const root = { id: 'runtime-root', submorphs: [] }; + const runtimeChild = { id: 'runtime-child', owner: null }; + const resolveMorph = id => id === root.id + ? root + : id === runtimeChild.id ? runtimeChild : null; + const context = { + resolveMorph, + moveMorph: (morph, from, to) => { + if (from.ownerId) { + resolveMorph(from.ownerId).submorphs.splice(from.index, 1); + morph.owner = null; + } + if (to.ownerId) { + const owner = resolveMorph(to.ownerId); + owner.submorphs.splice(to.index, 0, morph); + morph.owner = owner; + } + } + }; + + expect(projection.supported).to.be.true; + expect(projection.changeSet.operations[0]).containSubset({ + morphId: runtimeChild.id, + to: { ownerId: root.id, index: 0 } + }); + projection.changeSet.apply(context); + expect(root.submorphs).deep.equals([runtimeChild]); + expect(runtimeChild.owner).equals(root); + projection.inverseChangeSet.apply(context); + expect(root.submorphs).deep.equals([]); + expect(runtimeChild.owner).equals(null); + }); + + it('projects sibling reordering as an exact reversible attachment change', () => { + const children = ['first', 'second', 'third'].map(name => new ComponentNode({ + id: name, + name, + provenance: localNodeProvenance() + })); + const document = new ComponentDocument({ + componentId: 'component', + moduleId: 'local://component.js', + exportName: 'Component', + root: new ComponentNode({ + id: 'root', + name: 'root', + provenance: localNodeProvenance(), + children + }) + }); + const reduction = reduceComponent(document, MoveNode({ + componentId: document.componentId, + expectedRevision: document.revision, + nodeId: 'third', + parentId: 'root', + beforeId: 'first' + })); + const projection = projectComponentRuntime({ + beforeDocument: document, + reduction, + changeSetId: 'runtime-reorder', + resolveRuntimeTargetId: id => `runtime-${id}` + }); + const root = { id: 'runtime-root' }; + const runtimeChildren = children.map(({ id }) => ({ id: `runtime-${id}`, owner: root })); + root.submorphs = runtimeChildren.slice(); + const targets = new Map([[root.id, root], ...runtimeChildren.map(child => [child.id, child])]); + const context = { + resolveMorph: id => targets.get(id), + moveMorph: (morph, from, to) => { + targets.get(from.ownerId).submorphs.splice(from.index, 1); + const owner = targets.get(to.ownerId); + owner.submorphs.splice(to.index, 0, morph); + morph.owner = owner; + } + }; + + expect(projection.supported).to.be.true; + expect(projection.changeSet.operations[0]).containSubset({ + morphId: 'runtime-third', + from: { ownerId: root.id, index: 2 }, + to: { ownerId: root.id, index: 0 } + }); + projection.changeSet.apply(context); + expect(root.submorphs.map(({ id }) => id)) + .deep.equals(['runtime-third', 'runtime-first', 'runtime-second']); + projection.inverseChangeSet.apply(context); + expect(root.submorphs.map(({ id }) => id)) + .deep.equals(['runtime-first', 'runtime-second', 'runtime-third']); + }); + + it('projects reparenting and its collision rename as exact reversible changes', () => { + const moved = new ComponentNode({ + id: 'moved', name: 'moved', provenance: localNodeProvenance() + }); + const sourceParent = new ComponentNode({ + id: 'source', name: 'source', provenance: localNodeProvenance(), children: [moved] + }); + const destination = new ComponentNode({ + id: 'destination', name: 'destination', provenance: localNodeProvenance() + }); + const document = new ComponentDocument({ + componentId: 'component', + moduleId: 'local://component.js', + exportName: 'Component', + root: new ComponentNode({ + id: 'root', name: 'root', provenance: localNodeProvenance(), + children: [sourceParent, destination] + }) + }); + const reduction = reduceComponent(document, MoveNode({ + componentId: document.componentId, + expectedRevision: document.revision, + nodeId: moved.id, + parentId: destination.id, + beforeId: null + })); + const projection = projectComponentRuntime({ + beforeDocument: document, + reduction, + changeSetId: 'runtime-reparent', + resolveRuntimeTargetId: id => `runtime-${id}`, + runtimeRename: { before: 'moved', after: 'moved_1' } + }); + const root = { id: 'runtime-root' }; + const runtimeSource = { id: 'runtime-source', owner: root }; + const runtimeDestination = { id: 'runtime-destination', owner: root }; + const runtimeMoved = { id: 'runtime-moved', name: 'moved', owner: runtimeSource }; + root.submorphs = [runtimeSource, runtimeDestination]; + runtimeSource.submorphs = [runtimeMoved]; + runtimeDestination.submorphs = []; + const targets = new Map([root, runtimeSource, runtimeDestination, runtimeMoved] + .map(target => [target.id, target])); + const context = { + resolveMorph: id => targets.get(id), + moveMorph: (morph, from, to) => { + targets.get(from.ownerId).submorphs.splice(from.index, 1); + const owner = targets.get(to.ownerId); + owner.submorphs.splice(to.index, 0, morph); + morph.owner = owner; + } + }; + + expect(projection.supported).to.be.true; + projection.changeSet.apply(context); + expect(runtimeSource.submorphs).deep.equals([]); + expect(runtimeDestination.submorphs).deep.equals([runtimeMoved]); + expect(runtimeMoved.owner).equals(runtimeDestination); + expect(runtimeMoved.name).equals('moved_1'); + projection.inverseChangeSet.apply(context); + expect(runtimeSource.submorphs).deep.equals([runtimeMoved]); + expect(runtimeDestination.submorphs).deep.equals([]); + expect(runtimeMoved.owner).equals(runtimeSource); + expect(runtimeMoved.name).equals('moved'); + }); + + it('projects inherited suppression and restoration as inverse attachments', () => { + const inherited = new ComponentNode({ + id: 'inherited', name: 'inherited', provenance: inheritedNodeProvenance() + }); + const document = new ComponentDocument({ + componentId: 'component', moduleId: 'local://component.js', exportName: 'Component', + root: new ComponentNode({ + id: 'root', name: 'root', provenance: localNodeProvenance(), children: [inherited] + }) + }); + const suppressed = reduceComponent(document, SuppressInheritedNode({ + componentId: document.componentId, + expectedRevision: document.revision, + nodeId: inherited.id + })); + const projection = projectComponentRuntime({ + beforeDocument: document, + reduction: suppressed, + changeSetId: 'runtime-suppression', + resolveRuntimeTargetId: id => `runtime-${id}` + }); + + expect(projection.supported).to.be.true; + expect(projection.changeSet.operations[0]).containSubset({ + morphId: 'runtime-inherited', + from: { ownerId: 'runtime-root', index: 0 }, + to: { kind: 'detached' } + }); + + const restored = reduceComponent(suppressed.document, RestoreInheritedNode({ + componentId: document.componentId, + expectedRevision: suppressed.document.revision, + nodeId: inherited.id, + parentId: document.root.id, + beforeId: null + })); + const restoration = projectComponentRuntime({ + beforeDocument: suppressed.document, + reduction: restored, + changeSetId: 'runtime-restoration', + resolveRuntimeTargetId: id => `runtime-${id}` + }); + expect(restoration.changeSet.operations[0]).containSubset({ + morphId: 'runtime-inherited', + from: { kind: 'detached' }, + to: { ownerId: 'runtime-root', index: 0 } + }); + }); + + it('projects cached derived moves as exact reversible runtime changes', () => { + const beforeDocument = derivedStructureDocument(); + const afterDocument = derivedStructureDocument({ moved: true }); + const runtime = derivedRuntimeStructure(); + const projection = projectCachedDerivedRuntimeStructure({ + components: [derivedComponentPlan(beforeDocument, afterDocument, runtime)], + nodeId: 'target', + commandKind: DerivedRuntimeStructureProjectionKind.MOVE, + changeSetId: 'derived-move' + }); + const operation = projection.changeSet.operations[0]; + + expect(operation.morphId).equals(runtime.target.id); + expect(operation.from.ownerId).equals(runtime.left.id); + expect(operation.from.index).equals(0); + expect(operation.to.ownerId).equals(runtime.right.id); + expect(operation.to.index).equals(1); + expect(projection.inverseChangeSet.operations[0].from).deep.equals(operation.to); + expect(projection.inverseChangeSet.operations[0].to).deep.equals(operation.from); + expect(projection.resolveMorph(runtime.target.id)).equals(runtime.target); + }); + + it('projects cached derived removals from the exact runtime owner and index', () => { + const beforeDocument = derivedStructureDocument(); + const afterDocument = derivedStructureDocument({ removed: true }); + const runtime = derivedRuntimeStructure(); + const projection = projectCachedDerivedRuntimeStructure({ + components: [derivedComponentPlan(beforeDocument, afterDocument, runtime)], + nodeId: 'target', + commandKind: DerivedRuntimeStructureProjectionKind.REMOVE, + changeSetId: 'derived-removal' + }); + const operation = projection.changeSet.operations[0]; + + expect(operation.morphId).equals(runtime.target.id); + expect(operation.from.ownerId).equals(runtime.left.id); + expect(operation.from.index).equals(0); + expect(operation.to.kind).equals('detached'); + }); + + it('projects plain cached derived introductions from an exact runtime copy', () => { + const beforeDocument = derivedStructureDocument({ removed: true }); + const afterDocument = derivedStructureDocument(); + const runtime = derivedRuntimeStructure(); + runtime.left.submorphs = []; + runtime.target.owner = null; + const copiedTarget = { + id: 'derived-runtime-target-copy', + name: 'target', + owner: null, + submorphs: [] + }; + const sourceMorph = { + id: 'base-runtime-target', + name: 'target', + owner: { id: 'base-runtime-left' }, + submorphs: [], + copy: () => copiedTarget + }; + const projection = projectCachedDerivedRuntimeStructure({ + components: [derivedComponentPlan(beforeDocument, afterDocument, runtime)], + nodeId: 'target', + commandKind: DerivedRuntimeStructureProjectionKind.INTRODUCE, + changeSetId: 'derived-introduction', + sourceMorph + }); + const operation = projection.changeSet.operations[0]; + + expect(operation.morphId).equals(copiedTarget.id); + expect(operation.from.kind).equals('detached'); + expect(operation.to.ownerId).equals(runtime.left.id); + expect(operation.to.index).equals(0); + expect(projection.resolveMorph(copiedTarget.id)).equals(copiedTarget); + expect(projection.inverseChangeSet.operations[0].from).deep.equals(operation.to); + expect(projection.inverseChangeSet.operations[0].to).deep.equals(operation.from); + }); + + it('rejects copy-based introductions that require derived-local synthesis', () => { + const beforeDocument = derivedStructureDocument({ removed: true }); + const afterDocument = derivedStructureDocument({ targetHasOverrides: true }); + const runtime = derivedRuntimeStructure(); + runtime.left.submorphs = []; + const sourceMorph = { + id: 'base-runtime-target', + name: 'target', + owner: { id: 'base-runtime-left' }, + submorphs: [], + copy: () => ({ + id: 'derived-runtime-target-copy', + name: 'target', + owner: null, + submorphs: [] + }) + }; + + expect(() => projectCachedDerivedRuntimeStructure({ + components: [derivedComponentPlan(beforeDocument, afterDocument, runtime)], + nodeId: 'target', + commandKind: DerivedRuntimeStructureProjectionKind.INTRODUCE, + changeSetId: 'derived-overridden-introduction', + sourceMorph + })).to.throw('requires local synthesis'); + }); +}); diff --git a/lively.ide/tests/components/source-adapter-test.js b/lively.ide/tests/components/source-adapter-test.js new file mode 100644 index 0000000000..46c6ecac83 --- /dev/null +++ b/lively.ide/tests/components/source-adapter-test.js @@ -0,0 +1,538 @@ +/* global describe, it */ +import { expect } from 'mocha-es6'; +import { + ComponentDocument, + ComponentLayoutKind, + ComponentLayoutReferenceKind, + ComponentNode, + ComponentNodeProvenanceKind, + ComponentPropertyKind, + ComponentReferenceKind, + localNodeProvenance +} from '../../components/reconciliation/component-document.js'; +import { + ComponentSourceDiagnosticKind, + parseComponentSource +} from '../../components/reconciliation/source-adapter.js'; +import { ComponentImportKind } from '../../components/reconciliation/import-bindings.js'; + +const moduleId = 'local://projectional-source-test/component.cp.js'; + +function parseSource (source, exportName = 'Example') { + return parseComponentSource({ source, moduleId, exportName }); +} + +describe('projectional component source adapter', () => { + it('parses simple component trees and preserves opaque expressions', () => { + const source = ` +import { component } from 'lively.morphic/components/core.js'; +import { Color, pt as point } from 'lively.graphics'; +import { Text } from 'lively.morphic'; + +export const Example = component({ + name: 'example', + fill: Color.red, + opacity: 0.5, + reactsToPointer: true, + padding: { top: 1, right: 2, bottom: 3, left: 4 }, + extent: point(100, 50), + submorphs: [{ + type: Text, + name: 'label', + textString: 'hello', + fontSize: 14 + }] +});`; + const parsed = parseSource(source); + const { document } = parsed; + + expect(parsed.supported).to.be.true; + expect(document.componentId).equals(`${moduleId}#Example`); + expect(document.root.id).equals(`${moduleId}#Example:root`); + expect(document.root.properties.fill.kind) + .equals(ComponentPropertyKind.OPAQUE_EXPRESSION); + expect(document.root.properties.fill.expression).equals('Color.red'); + expect(document.root.properties.opacity.value).equals(0.5); + expect(document.root.properties.padding.value) + .deep.equals({ top: 1, right: 2, bottom: 3, left: 4 }); + expect(document.root.properties.extent.expression).equals('point(100, 50)'); + expect(document.root.children[0].name).equals('label'); + expect(document.root.children[0].typeExpression).equals('Text'); + expect(document.root.children[0].properties.textString.value).equals('hello'); + expect(document.sourceMetadata.importBindings).deep.include({ + kind: ComponentImportKind.NAMED, + moduleId: 'lively.graphics', + imported: 'pt', + local: 'point' + }); + const fillRange = document.sourceMetadata.propertyLocations[document.root.id].fill.value; + expect(source.slice(fillRange.start, fillRange.end)).equals('Color.red'); + }); + + it('keeps owners with generated submorphs projectionally editable', () => { + const source = `const Example = component({ + name: 'example', + submorphs: [{ + name: 'date array', + layout: new TilingLayout({ spacing: 2 }), + submorphs: arr.range(1, 41).map(i => part(DateDefault, { + name: 'day ' + i + })) + }] +});`; + const parsed = parseSource(source); + const dateArray = parsed.document.root.children[0]; + const opaqueRange = parsed.document.sourceMetadata + .opaqueSubmorphExpressions[dateArray.id]; + + expect(parsed.supported).to.be.true; + expect(dateArray.name).equals('date array'); + expect(dateArray.children).deep.equals([]); + expect(parsed.diagnostics[0]).include({ + kind: ComponentSourceDiagnosticKind.OPAQUE_SUBMORPH_STRUCTURE, + severity: 'warning', + ownerId: dateArray.id + }); + expect(source.slice(opaqueRange.start, opaqueRange.end)) + .equals(`arr.range(1, 41).map(i => part(DateDefault, { + name: 'day ' + i + }))`); + }); + + it('models static tiling-layout resize policies as stable node references', () => { + const source = `const Example = component({ + name: 'example', + layout: new TilingLayout({ + resizePolicies: [['label', { height: 'fixed', width: 'fill' }]] + }), + submorphs: [{ name: 'label' }, { name: 'icon' }] +});`; + const { supported, document, diagnostics } = parseSource(source); + const label = document.root.children[0]; + const [layoutModel] = document.layoutModels; + const [reference] = layoutModel.references; + const location = document.sourceMetadata + .layoutReferenceLocations[document.root.id][label.id]; + + expect(supported).to.be.true; + expect(diagnostics).deep.equals([]); + expect(layoutModel.kind).equals(ComponentLayoutKind.TILING); + expect(layoutModel.ownerId).equals(document.root.id); + expect(layoutModel.expressionTemplate).includes(''); + expect(reference.kind).equals(ComponentLayoutReferenceKind.RESIZE_POLICY); + expect(reference.targetId).equals(label.id); + expect(reference.expressionTemplate).includes(''); + expect(source.slice(location.target.start, location.target.end)).equals("'label'"); + expect(source.slice(location.entry.start, location.entry.end)) + .equals("['label', { height: 'fixed', width: 'fill' }]"); + }); + + it('keeps dynamic and unresolved tiling-layout references outside the semantic model', () => { + const dynamic = parseSource(`const Example = component({ + name: 'example', + layout: new TilingLayout({ resizePolicies: policies }), + submorphs: [{ name: 'label' }] +});`); + const unresolved = parseSource(`const Example = component({ + name: 'example', + layout: new TilingLayout({ resizePolicies: [['missing', { width: 'fill' }]] }), + submorphs: [{ name: 'label' }] +});`); + + expect(dynamic.supported).to.be.true; + expect(dynamic.document.layoutModels).deep.equals([]); + expect(dynamic.diagnostics.map(({ kind }) => kind)) + .deep.equals([ComponentSourceDiagnosticKind.UNMODELED_LAYOUT_REFERENCE]); + expect(unresolved.supported).to.be.true; + expect(unresolved.document.layoutModels).deep.equals([]); + expect(unresolved.diagnostics[0]).include({ + kind: ComponentSourceDiagnosticKind.UNMODELED_LAYOUT_REFERENCE, + targetName: 'missing' + }); + }); + + it('parses root-only derived overrides with an explicit parent reference', () => { + const source = `const Example = component(BaseComponent, { + name: 'derived', + fill: Color.blue +});`; + const { supported, document } = parseSource(source); + + expect(supported).to.be.true; + expect(document.parentComponent.kind).equals(ComponentReferenceKind.SOURCE_EXPRESSION); + expect(document.parentComponent.expression).equals('BaseComponent'); + expect(document.root.properties.fill.expression).equals('Color.blue'); + }); + + it('merges resolved inherited nodes with derived without markers', () => { + const parentDocument = new ComponentDocument({ + componentId: 'parent', + moduleId: 'local://parent.cp.js', + exportName: 'Parent', + root: new ComponentNode({ + id: 'parent-root', name: 'parent', provenance: localNodeProvenance(), + children: [new ComponentNode({ + id: 'inherited-child', name: 'inherited child', provenance: localNodeProvenance() + })] + }) + }); + const source = `const Example = component(BaseComponent, { + name: 'derived', + submorphs: [without('inherited child')] +});`; + const parsed = parseComponentSource({ + source, moduleId, exportName: 'Example', parentDocument + }); + const inherited = parsed.document.root.children[0]; + + expect(parsed.supported).to.be.true; + expect(inherited.id).equals('inherited-child'); + expect(inherited.provenance.kind).equals(ComponentNodeProvenanceKind.INHERITED); + expect(inherited.provenance.suppressed).to.be.true; + const location = parsed.document.sourceMetadata.suppressionLocations[inherited.id]; + expect(source.slice(location.start, location.end)).equals("without('inherited child')"); + }); + + it('keeps inherited selectors stable when replace renames an override', () => { + const parentDocument = new ComponentDocument({ + componentId: 'parent', + moduleId: 'local://parent.cp.js', + exportName: 'Parent', + root: new ComponentNode({ + id: 'parent-root', name: 'parent', provenance: localNodeProvenance(), + children: [new ComponentNode({ + id: 'inherited-child', name: 'before', provenance: localNodeProvenance() + })] + }) + }); + const source = `const Example = component(BaseComponent, { + name: 'derived', + submorphs: [replace('before', { name: 'after', fill: 'red' })] +});`; + const parsed = parseComponentSource({ + source, moduleId, exportName: 'Example', parentDocument + }); + const inherited = parsed.document.root.children[0]; + + expect(parsed.supported).to.be.true; + expect(inherited.id).equals('inherited-child'); + expect(inherited.name).equals('after'); + expect(inherited.provenance.kind).equals(ComponentNodeProvenanceKind.INHERITED); + expect(inherited.provenance.baseName).equals('before'); + expect(inherited.provenance.hasLocalOverrides).to.be.true; + expect(inherited.properties.fill.value).equals('red'); + const location = parsed.document.sourceMetadata.nodeIdToAstLocation[inherited.id]; + expect(source.slice(location.start, location.end)) + .equals("replace('before', { name: 'after', fill: 'red' })"); + }); + + it('merges nested derived overrides and additions into a resolved parent tree', () => { + const parentDocument = new ComponentDocument({ + componentId: 'parent', + moduleId: 'local://parent.cp.js', + exportName: 'Parent', + root: new ComponentNode({ + id: 'parent-root', name: 'parent', provenance: localNodeProvenance(), + children: [new ComponentNode({ + id: 'parent-group', name: 'group', provenance: localNodeProvenance(), + children: [new ComponentNode({ + id: 'parent-label', name: 'label', provenance: localNodeProvenance() + }), new ComponentNode({ + id: 'parent-body', name: 'body', provenance: localNodeProvenance() + })] + }), new ComponentNode({ + id: 'parent-existing', name: 'existing', provenance: localNodeProvenance() + })] + }) + }); + const cardDocument = new ComponentDocument({ + componentId: 'card', + moduleId: 'local://card.cp.js', + exportName: 'Card', + root: new ComponentNode({ + id: 'card-root', name: 'card', provenance: localNodeProvenance() + }) + }); + const source = `const Example = component(BaseComponent, { + name: 'derived', + submorphs: [{ + name: 'group', + opacity: 0.5, + submorphs: [without('label'), add({ name: 'badge' }, 'body')] + }, add(part(Card), 'existing')] +});`; + const parsed = parseComponentSource({ + source, + moduleId, + exportName: 'Example', + parentDocument, + resolveComponentDocument: ({ expression }) => + expression === 'Card' ? cardDocument : null + }); + const [group, card, existing] = parsed.document.root.children; + const [label, badge, body] = group.children; + + expect(parsed.supported).to.be.true; + expect(group.id).equals('parent-group'); + expect(group.provenance.hasLocalOverrides).to.be.true; + expect(group.properties.opacity.value).equals(0.5); + expect(label.id).equals('parent-label'); + expect(label.provenance.suppressed).to.be.true; + expect(badge.provenance.beforeId).equals(body.id); + expect(card.name).equals('card'); + expect(card.provenance.kind).equals(ComponentNodeProvenanceKind.ADDED); + expect(card.provenance.beforeId).equals(existing.id); + expect(existing.id).equals('parent-existing'); + }); + + it('uses deterministic source-path identities across equivalent parses', () => { + const source = `const Example = component({ + name: 'example', + submorphs: [{ name: 'first' }, { name: 'second' }] +});`; + const first = parseSource(source).document; + const second = parseSource(source).document; + + expect(first).deep.equals(second); + expect(first.root.children.map(({ id }) => id)).deep.equals([ + `${moduleId}#Example:node:0`, + `${moduleId}#Example:node:1` + ]); + }); + + it('parses named part and add structures with semantic references and ordering anchors', () => { + const source = `const Example = component({ + name: 'example', + submorphs: [part(Card, { + name: 'card', + submorphs: [add({ name: 'badge' }, 'label'), { name: 'label' }] + }), add(part(Button, { name: 'button' }), 'card')] +});`; + const parsed = parseSource(source); + const card = parsed.document.root.children.find(({ name }) => name === 'card'); + const button = parsed.document.root.children.find(({ name }) => name === 'button'); + const [badge, label] = card.children; + + expect(parsed.supported).to.be.true; + expect(card.partComponent.expression).equals('Card'); + expect(card.provenance.kind).equals(ComponentNodeProvenanceKind.LOCAL); + expect(button.partComponent.expression).equals('Button'); + expect(button.provenance.kind).equals(ComponentNodeProvenanceKind.ADDED); + expect(button.provenance.beforeId).equals(card.id); + expect(parsed.document.root.children.map(({ name }) => name)) + .deep.equals(['button', 'card']); + expect(badge.provenance.kind).equals(ComponentNodeProvenanceKind.ADDED); + expect(badge.provenance.beforeId).equals(label.id); + const buttonLocation = parsed.document.sourceMetadata.nodeIdToAstLocation[button.id]; + expect(source.slice(buttonLocation.start, buttonLocation.end)) + .equals("add(part(Button, { name: 'button' }), 'card')"); + const buttonSpecLocation = parsed.document.sourceMetadata.nodeSpecLocations[button.id]; + expect(source.slice(buttonSpecLocation.start, buttonSpecLocation.end)) + .equals("{ name: 'button' }"); + }); + + it('preserves static ordering anchors to unresolved inherited part children', () => { + const parsed = parseSource(`const Example = component({ + name: 'example', + submorphs: [part(Card, { + name: 'card', + submorphs: [ + add({ name: 'moved' }, 'inherited child'), + add({ name: 'local addition' }) + ] + })] +});`); + const [moved, localAddition] = parsed.document.root.children[0].children; + + expect(parsed.supported).to.be.true; + expect([moved.name, localAddition.name]) + .deep.equals(['moved', 'local addition']); + expect(moved.provenance).containSubset({ + kind: ComponentNodeProvenanceKind.ADDED, + beforeId: null, + beforeName: 'inherited child' + }); + }); + + it('models named overrides in unresolved parts as inherited edit targets', () => { + const parsed = parseSource(`const Example = component({ + name: 'example', + submorphs: [part(Card, { + name: 'card', + submorphs: [{ + name: 'inherited child', + submorphs: [add({ name: 'addition' })] + }] + })] +});`); + const inherited = parsed.document.root.children[0].children[0]; + + expect(parsed.supported).to.be.true; + expect(inherited.provenance).containSubset({ + kind: ComponentNodeProvenanceKind.INHERITED, + suppressed: false, + hasLocalOverrides: true, + baseName: 'inherited child' + }); + expect(inherited.children[0].provenance.kind) + .equals(ComponentNodeProvenanceKind.ADDED); + }); + + it('keeps nested named overrides in unresolved parts visible recursively', () => { + const parsed = parseSource(`const Example = component({ + name: 'example', + submorphs: [part(Card, { + name: 'card', + submorphs: [{ + name: 'inherited child', + submorphs: [{ name: 'nested inherited child', opacity: 0.5 }] + }] + })] +});`); + const inherited = parsed.document.root.children[0].children[0]; + const nestedInherited = inherited.children[0]; + + expect(parsed.supported).to.be.true; + expect(inherited.provenance).containSubset({ + kind: ComponentNodeProvenanceKind.INHERITED, + suppressed: false, + hasLocalOverrides: true + }); + expect(nestedInherited.provenance).containSubset({ + kind: ComponentNodeProvenanceKind.INHERITED, + suppressed: false, + hasLocalOverrides: true, + baseName: 'nested inherited child' + }); + expect(nestedInherited.properties.opacity.value).equals(0.5); + }); + + it('resolves unnamed parts and nested structural overrides with instance-local identities', () => { + const cardDocument = new ComponentDocument({ + componentId: 'card', + moduleId: 'local://card.cp.js', + exportName: 'Card', + root: new ComponentNode({ + id: 'card-root', name: 'card', provenance: localNodeProvenance(), + children: [new ComponentNode({ + id: 'card-label', name: 'label', provenance: localNodeProvenance(), + children: [new ComponentNode({ + id: 'card-icon', name: 'icon', provenance: localNodeProvenance() + })] + }), new ComponentNode({ + id: 'card-body', name: 'body', provenance: localNodeProvenance() + })] + }) + }); + const source = `const Example = component({ + name: 'example', + submorphs: [part(Card, { + name: 'first card', + submorphs: [{ + name: 'label', + textString: 'overridden', + submorphs: [without('icon'), add({ name: 'badge' })] + }, add({ name: 'footer' }, 'body')] + }), part(Card)] +});`; + const parsed = parseComponentSource({ + source, + moduleId, + exportName: 'Example', + resolveComponentDocument: ({ expression }) => + expression === 'Card' ? cardDocument : null + }); + const [firstCard, secondCard] = parsed.document.root.children; + const label = firstCard.children.find(({ name }) => name === 'label'); + const icon = label.children.find(({ name }) => name === 'icon'); + const badge = label.children.find(({ name }) => name === 'badge'); + + expect(parsed.supported).to.be.true; + expect(secondCard.name).equals('card'); + expect(firstCard.children.map(({ name }) => name)) + .deep.equals(['label', 'footer', 'body']); + expect(label.provenance).deep.equals({ + kind: ComponentNodeProvenanceKind.INHERITED, + suppressed: false, + hasLocalOverrides: true, + beforeId: null, + baseName: 'label' + }); + expect(label.properties.textString.value).equals('overridden'); + expect(icon.provenance.suppressed).to.be.true; + expect(badge.provenance.kind).equals(ComponentNodeProvenanceKind.ADDED); + expect(firstCard.children.find(({ name }) => name === 'footer').provenance.beforeId) + .equals(firstCard.children.find(({ name }) => name === 'body').id); + expect(firstCard.children[0].id).not.equals(secondCard.children[0].id); + expect(new Set([ + firstCard.children[0].id, + firstCard.children[1].id, + firstCard.children[2].id, + secondCard.children[0].id, + secondCard.children[1].id + ]).size).equals(5); + }); + + it('reports unresolved derived and unnamed part structures without partial documents', () => { + const derived = parseSource(`const Example = component(Base, { + name: 'derived', + submorphs: [{ name: 'override' }] +});`); + const unresolvedPart = parseSource(`const Example = component({ + name: 'example', + submorphs: [part(Other)] +});`); + + expect(derived.supported).to.be.false; + expect(derived.document).equals(null); + expect(derived.diagnostics[0].kind) + .equals(ComponentSourceDiagnosticKind.DERIVED_STRUCTURE_REQUIRES_PARENT); + expect(unresolvedPart.supported).to.be.false; + expect(unresolvedPart.document).equals(null); + expect(unresolvedPart.diagnostics[0].kind) + .equals(ComponentSourceDiagnosticKind.UNRESOLVED_PART_COMPONENT); + }); + + it('rejects unknown nested override targets and dynamic ordering anchors', () => { + const partDocument = new ComponentDocument({ + componentId: 'part', + moduleId: 'local://part.cp.js', + exportName: 'Part', + root: new ComponentNode({ + id: 'part-root', name: 'part', provenance: localNodeProvenance(), + children: [new ComponentNode({ + id: 'known', name: 'known', provenance: localNodeProvenance() + })] + }) + }); + const unknownOverride = parseComponentSource({ + source: `const Example = component({ + submorphs: [part(Part, { name: 'instance', submorphs: [{ name: 'missing' }] })] +});`, + moduleId, + exportName: 'Example', + resolveComponentDocument: () => partDocument + }); + const dynamicAnchor = parseSource(`const Example = component({ + submorphs: [add({ name: 'addition' }, selectedAnchor), { name: 'target' }] +});`); + + expect(unknownOverride.supported).to.be.false; + expect(unknownOverride.document).equals(null); + expect(unknownOverride.diagnostics[0].kind) + .equals(ComponentSourceDiagnosticKind.UNSUPPORTED_SUBMORPH_STRUCTURE); + expect(dynamicAnchor.supported).to.be.false; + expect(dynamicAnchor.document).equals(null); + expect(dynamicAnchor.diagnostics[0].kind) + .equals(ComponentSourceDiagnosticKind.INVALID_ORDERING_REFERENCE); + }); + + it('returns diagnostics for syntax errors and missing declarations', () => { + const invalid = parseSource('const Example = component({'); + const missing = parseSource('const Different = component({ name: \'different\' });'); + + expect(invalid.diagnostics[0].kind).equals(ComponentSourceDiagnosticKind.SYNTAX_ERROR); + expect(missing.diagnostics[0].kind) + .equals(ComponentSourceDiagnosticKind.COMPONENT_NOT_FOUND); + }); +}); diff --git a/lively.ide/tests/components/source-projector-test.js b/lively.ide/tests/components/source-projector-test.js new file mode 100644 index 0000000000..0d70ea73a7 --- /dev/null +++ b/lively.ide/tests/components/source-projector-test.js @@ -0,0 +1,1808 @@ +/* global describe, it */ +import { expect } from 'mocha-es6'; +import { + ClearPropertyOverride, + ComponentMoveInheritanceTransitionKind, + ComponentTextEditKind, + EditText, + IntroduceNode, + MoveNode, + RemoveNode, + RenameNode, + RestoreInheritedNode, + SetOpaqueProperty, + SetMaster, + SetProperty, + SuppressInheritedNode +} from '../../components/reconciliation/commands.js'; +import { reduceComponent } from '../../components/reconciliation/reducer.js'; +import { + ComponentDocument, + ComponentNode, + addedNodeProvenance, + explicitProperty, + inheritedNodeProvenance, + localNodeProvenance, + opaqueProperty, + sourceComponentReference +} from '../../components/reconciliation/component-document.js'; +import { parseComponentSource } from '../../components/reconciliation/source-adapter.js'; +import { + alignParsedDocumentIdentities, + componentDocumentsSemanticallyEqual, + projectComponentSource +} from '../../components/reconciliation/source-projector.js'; +import { + ComponentImportKind, + componentImportBinding +} from '../../components/reconciliation/import-bindings.js'; + +const moduleId = 'local://projectional-source-test/component.cp.js'; +const componentId = `${moduleId}#Example`; + +function parsed (source) { + return parseComponentSource({ + source, + moduleId, + exportName: 'Example', + componentId + }).document; +} + +function reduce (document, commandFactory, spec) { + return reduceComponent(document, commandFactory({ + componentId, + expectedRevision: document.revision, + ...spec + })); +} + +describe('projectional component source projector', () => { + it('aligns stable identities locally when an unrelated subtree shape differs', () => { + const expected = parsed(`const Example = component({ + name: 'example', + submorphs: [ + { name: 'unresolved', submorphs: [{ name: 'known' }] }, + { name: 'ordered', submorphs: [{ name: 'first' }, { name: 'moved' }] } + ] +});`); + const reparsed = parsed(`const Example = component({ + name: 'example', + submorphs: [ + { name: 'unresolved', submorphs: [{ name: 'known' }, { name: 'extra' }] }, + { name: 'ordered', submorphs: [{ name: 'moved' }, { name: 'first' }] } + ] +});`); + const aligned = alignParsedDocumentIdentities(reparsed, expected); + const expectedMoved = expected.root.children[1].children[1]; + const alignedMoved = aligned.root.children[1].children[0]; + + expect(alignedMoved.name).equals('moved'); + expect(alignedMoved.id).equals(expectedMoved.id); + }); + + it('treats property map insertion order as semantically irrelevant', () => { + const first = parsed(`const Example = component({ + name: 'example', + fill: 'red', + opacity: 0.5 +});`); + const second = parsed(`const Example = component({ + opacity: 0.5, + name: 'example', + fill: 'red' +});`); + + expect(componentDocumentsSemanticallyEqual(first, second)).to.be.true; + }); + + it('replaces explicit and opaque property expressions', () => { + const source = `const Example = component({ + name: 'example', + fill: Color.red +});`; + const document = parsed(source); + const explicit = reduce(document, SetProperty, { + nodeId: document.root.id, + property: 'fill', + value: 'green' + }); + const explicitProjection = projectComponentSource({ + source, + beforeDocument: document, + reduction: explicit + }); + + expect(explicitProjection.supported).to.be.true; + expect(explicitProjection.sourceAfter).includes('fill: "green"'); + + const opaque = reduce(document, SetOpaqueProperty, { + nodeId: document.root.id, + property: 'fill', + expression: 'Color.green' + }); + const opaqueProjection = projectComponentSource({ + source, + beforeDocument: document, + reduction: opaque + }); + expect(opaqueProjection.sourceAfter).includes('fill: Color.green'); + }); + + it('canonicalizes a static opaque expression after source reparsing', () => { + const source = `const Example = component({ name: 'example' });`; + const document = parsed(source); + const reduction = reduce(document, SetOpaqueProperty, { + nodeId: document.root.id, + property: 'borderWidth', + expression: '0' + }); + const projection = projectComponentSource({ + source, + beforeDocument: document, + reduction + }); + + expect(projection.supported, JSON.stringify(projection.diagnostics)).to.be.true; + expect(projection.sourceAfter).includes('borderWidth: 0'); + expect(projection.projectedDocument.root.properties.borderWidth.value).equals(0); + expect(projection.projectedDocument.root.properties.borderWidth.expression) + .equals(undefined); + }); + + it('projects required imports together with opaque property expressions', () => { + const source = `const Example = component({ fill: 'red' });`; + const document = parsed(source); + const reduction = reduce(document, SetOpaqueProperty, { + nodeId: document.root.id, + property: 'fill', + expression: 'Color.green', + requiredBindings: [componentImportBinding({ + kind: ComponentImportKind.NAMED, + moduleId: 'lively.graphics', + imported: 'Color', + local: 'Color' + })] + }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(projection.supported).to.be.true; + expect(projection.sourceAfter).equals(`import { Color } from "lively.graphics";\n\nconst Example = component({ fill: Color.green });`); + expect(projection.changes).length(2); + }); + + it('reuses package-root imports for equivalent submodule bindings', () => { + const source = `import { Color } from 'lively.graphics'; + +const Example = component({ fill: 'red' });`; + const document = parsed(source); + const reduction = reduce(document, SetOpaqueProperty, { + nodeId: document.root.id, + property: 'fill', + expression: 'Color.green', + requiredBindings: [componentImportBinding({ + kind: ComponentImportKind.NAMED, + moduleId: 'lively.graphics/color.js', + imported: 'Color', + local: 'Color' + })] + }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(projection.supported).to.be.true; + expect(projection.sourceAfter.match(/import \{ Color \}/g)).length(1); + expect(projection.sourceAfter).includes('fill: Color.green'); + }); + + it('projects master expressions and their imports as one source plan', () => { + const source = `const Example = component({ master: null });`; + const document = parsed(source); + const reduction = reduce(document, SetMaster, { + nodeId: document.root.id, + expression: 'HoverMaster', + requiredBindings: [componentImportBinding({ + kind: ComponentImportKind.NAMED, + moduleId: 'local://masters.js', + imported: 'HoverMaster', + local: 'HoverMaster' + })] + }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(projection.supported).to.be.true; + expect(projection.sourceAfter).includes('import { HoverMaster } from "local://masters.js";'); + expect(projection.sourceAfter).includes('master: HoverMaster'); + }); + + it('reuses matching aliased imports and rejects conflicting locals', () => { + const aliasSource = `import { Color as Hue } from 'lively.graphics';\n\nconst Example = component({ fill: 'red' });`; + const aliasDocument = parsed(aliasSource); + const requiredBinding = componentImportBinding({ + kind: ComponentImportKind.NAMED, + moduleId: 'lively.graphics', + imported: 'Color', + local: 'Hue' + }); + const aliasReduction = reduce(aliasDocument, SetOpaqueProperty, { + nodeId: aliasDocument.root.id, + property: 'fill', + expression: 'Hue.green', + requiredBindings: [requiredBinding] + }); + const aliasProjection = projectComponentSource({ + source: aliasSource, + beforeDocument: aliasDocument, + reduction: aliasReduction + }); + + expect(aliasProjection.supported).to.be.true; + expect(aliasProjection.sourceAfter.match(/from 'lively.graphics'/g)).length(1); + + const conflictSource = `import { pt as Hue } from 'lively.graphics';\n\nconst Example = component({ fill: 'red' });`; + const conflictDocument = parsed(conflictSource); + const conflictReduction = reduce(conflictDocument, SetOpaqueProperty, { + nodeId: conflictDocument.root.id, + property: 'fill', + expression: 'Hue.green', + requiredBindings: [requiredBinding] + }); + const conflictProjection = projectComponentSource({ + source: conflictSource, + beforeDocument: conflictDocument, + reduction: conflictReduction + }); + + expect(conflictProjection.supported).to.be.false; + expect(conflictProjection.sourceAfter).equals(conflictSource); + expect(conflictProjection.diagnostics[0].kind).equals('import-binding-conflict'); + }); + + it('projects default and namespace imports after side-effect imports', () => { + const source = `import 'initialize-theme';\n\nconst Example = component({ fill: 'red' });`; + const document = parsed(source); + const reduction = reduce(document, SetOpaqueProperty, { + nodeId: document.root.id, + property: 'fill', + expression: 'Theme.color(Palette.green)', + requiredBindings: [ + componentImportBinding({ + kind: ComponentImportKind.DEFAULT, + moduleId: 'theme', + local: 'Theme' + }), + componentImportBinding({ + kind: ComponentImportKind.NAMESPACE, + moduleId: 'palette', + local: 'Palette' + }) + ] + }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(projection.supported).to.be.true; + expect(projection.sourceAfter).includes(`import 'initialize-theme';\nimport Theme from "theme";\nimport * as Palette from "palette";`); + expect(projection.sourceAfter).includes('fill: Theme.color(Palette.green)'); + }); + + it('inserts properties before submorphs and reparses equivalent semantics', () => { + const source = `const Example = component({ + name: 'example', + submorphs: [{ name: 'child' }] +});`; + const document = parsed(source); + const reduction = reduce(document, SetProperty, { + nodeId: document.root.id, + property: 'opacity', + value: 0.5 + }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(projection.supported).to.be.true; + expect(projection.sourceAfter).includes(`opacity: 0.5, + submorphs`); + expect(projection.projectedDocument.root).deep.equals(reduction.document.root); + }); + + it('projects into a part override without replacing its component reference', () => { + const source = `const Example = component({ + name: 'example', + submorphs: [part(Card, { name: 'card' })] +});`; + const document = parsed(source); + const partNode = document.root.children[0]; + const reduction = reduce(document, SetProperty, { + nodeId: partNode.id, + property: 'opacity', + value: 0.5 + }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(projection.supported).to.be.true; + expect(projection.sourceAfter).includes(`part(Card, { name: 'card', + opacity: 0.5 })`); + expect(projection.projectedDocument.root.children[0].partComponent.expression) + .equals('Card'); + expect(componentDocumentsSemanticallyEqual( + projection.projectedDocument, + reduction.document + )).to.be.true; + }); + + it('projects a semantic full-text replacement without touching sibling properties', () => { + const source = `const Example = component({ + name: 'label', + textAndAttributes: ['before', null], + fill: 'red' +});`; + const document = parsed(source); + const reduction = reduce(document, EditText, { + nodeId: document.root.id, + operation: { + kind: ComponentTextEditKind.REPLACE_ALL, + before: ['before', null], + after: ['after', { fontWeight: 'bold' }] + } + }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(projection.supported).to.be.true; + expect(projection.sourceAfter).includes('textAndAttributes: ["after", { "fontWeight": "bold" }]'); + expect(projection.sourceAfter).includes("fill: 'red'"); + }); + + it('inserts the first local text expression for an inherited effective value', () => { + const source = `const Example = component({ + name: 'label', + fill: 'red' +});`; + const parsedDocument = parsed(source); + const document = new ComponentDocument({ + revision: parsedDocument.revision, + componentId: parsedDocument.componentId, + moduleId: parsedDocument.moduleId, + exportName: parsedDocument.exportName, + parentComponent: parsedDocument.parentComponent, + root: parsedDocument.root.with({ + properties: { + ...parsedDocument.root.properties, + textAndAttributes: explicitProperty(['before', null]) + } + }), + layoutModels: parsedDocument.layoutModels, + sourceMetadata: parsedDocument.sourceMetadata + }); + const reduction = reduce(document, EditText, { + nodeId: document.root.id, + operation: { + kind: ComponentTextEditKind.REPLACE_ALL, + before: ['before', null], + after: ['after', { fontWeight: 'bold' }] + } + }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(projection.supported).to.be.true; + expect(projection.sourceAfter) + .includes('textAndAttributes: ["after", { "fontWeight": "bold" }]'); + expect(projection.sourceAfter).includes("fill: 'red'"); + }); + + it('keeps source locations aligned across growing and shrinking text edits', () => { + const source = `const Example = component({ + name: 'label', + textAndAttributes: ['before', null], + fill: 'red' +});`; + const document = parsed(source); + const longerText = ['a considerably longer text value', { fontWeight: 'bold' }]; + const grown = projectComponentSource({ + source, + beforeDocument: document, + reduction: reduce(document, EditText, { + nodeId: document.root.id, + operation: { + kind: ComponentTextEditKind.REPLACE_ALL, + before: ['before', null], + after: longerText + } + }) + }); + expect(grown.supported).to.be.true; + expect(grown.projectedDocument.sourceMetadata.originalExpressions[document.root.id] + .textAndAttributes).equals('["a considerably longer text value", { "fontWeight": "bold" }]'); + + const recolored = projectComponentSource({ + source: grown.sourceAfter, + beforeDocument: grown.projectedDocument, + reduction: reduce(grown.projectedDocument, SetProperty, { + nodeId: document.root.id, + property: 'fill', + value: 'blue' + }) + }); + expect(recolored.supported).to.be.true; + expect(recolored.sourceAfter).includes('fill: "blue"'); + + const shrunk = projectComponentSource({ + source: recolored.sourceAfter, + beforeDocument: recolored.projectedDocument, + reduction: reduce(recolored.projectedDocument, EditText, { + nodeId: document.root.id, + operation: { + kind: ComponentTextEditKind.REPLACE_ALL, + before: longerText, + after: ['short', null] + } + }) + }); + expect(shrunk.supported).to.be.true; + expect(shrunk.sourceAfter).includes('textAndAttributes: ["short", null]'); + expect(shrunk.sourceAfter).includes('fill: "blue"'); + }); + + it('clears overrides and renames nested nodes without disturbing structure', () => { + const source = `const Example = component({ + name: 'example', + fill: Color.red, + submorphs: [{ name: 'child', opacity: 0.5 }] +});`; + const document = parsed(source); + const cleared = reduce(document, ClearPropertyOverride, { + nodeId: document.root.id, + property: 'fill' + }); + const clearProjection = projectComponentSource({ + source, + beforeDocument: document, + reduction: cleared + }); + expect(clearProjection.supported).to.be.true; + expect(clearProjection.sourceAfter).not.includes('fill:'); + expect(clearProjection.sourceAfter).includes('submorphs:'); + + const child = document.root.children[0]; + const renamed = reduce(document, RenameNode, { + nodeId: child.id, + name: 'renamed' + }); + const renameProjection = projectComponentSource({ + source, + beforeDocument: document, + reduction: renamed + }); + expect(renameProjection.supported).to.be.true; + expect(renameProjection.sourceAfter).includes("name: 'example'"); + expect(renameProjection.sourceAfter).includes('name: "renamed"'); + }); + + it('projects inherited renames through a stable replace selector', () => { + const parentDocument = new ComponentDocument({ + componentId: 'parent', + moduleId: 'local://parent.cp.js', + exportName: 'Parent', + root: new ComponentNode({ + id: 'parent-root', name: 'parent', provenance: localNodeProvenance(), + children: [new ComponentNode({ + id: 'inherited-child', name: 'before', provenance: localNodeProvenance() + })] + }) + }); + const source = `const Example = component(Base, { + name: 'derived', + submorphs: [{ name: 'before', fill: 'red' }] +});`; + const document = parseComponentSource({ + source, moduleId, exportName: 'Example', componentId, parentDocument + }).document; + const inherited = document.root.children[0]; + const renamed = reduce(document, RenameNode, { + nodeId: inherited.id, + name: 'after' + }); + const projection = projectComponentSource({ + source, beforeDocument: document, reduction: renamed + }); + + expect(projection.supported).to.be.true; + expect(projection.sourceAfter) + .includes('import { replace } from "lively.morphic/components/core.js";'); + expect(projection.sourceAfter) + .includes(`replace("before", { name: "after", fill: 'red' })`); + expect(projection.projectedDocument.root.children[0].provenance.baseName) + .equals('before'); + expect(componentDocumentsSemanticallyEqual( + projection.projectedDocument, + renamed.document + )).to.be.true; + + const renamedAgain = reduce(projection.projectedDocument, RenameNode, { + nodeId: inherited.id, + name: 'after again' + }); + const secondProjection = projectComponentSource({ + source: projection.sourceAfter, + beforeDocument: projection.projectedDocument, + reduction: renamedAgain + }); + expect(secondProjection.supported).to.be.true; + expect(secondProjection.sourceAfter.match(/replace\(/g)).length(1); + expect(secondProjection.sourceAfter).includes('name: "after again"'); + }); + + it('retargets added ordering anchors when renaming an inherited node', () => { + const parentDocument = new ComponentDocument({ + componentId: 'parent', + moduleId: 'local://parent.cp.js', + exportName: 'Parent', + root: new ComponentNode({ + id: 'parent-root', name: 'parent', provenance: localNodeProvenance(), + children: [new ComponentNode({ + id: 'inherited-child', name: 'before', provenance: localNodeProvenance() + })] + }) + }); + const source = `const Example = component(Base, { + name: 'derived', + submorphs: [ + add({ name: 'inserted' }, 'before'), + { name: 'before', fill: 'red' } + ] +});`; + const document = parseComponentSource({ + source, moduleId, exportName: 'Example', componentId, parentDocument + }).document; + const inherited = document.root.children.find(node => node.name === 'before'); + const reduction = reduce(document, RenameNode, { + nodeId: inherited.id, + name: 'after' + }); + const projection = projectComponentSource({ + source, + beforeDocument: document, + reduction + }); + + expect(projection.supported, JSON.stringify(projection.diagnostics)).to.be.true; + expect(projection.sourceAfter).includes(`add({ name: 'inserted' }, "after")`); + expect(projection.sourceAfter) + .includes(`replace("before", { name: "after", fill: 'red' })`); + expect(projection.sourceAfter.indexOf('replace("before"')) + .to.be.below(projection.sourceAfter.indexOf('add({ name: \'inserted\'')); + expect(componentDocumentsSemanticallyEqual( + projection.projectedDocument, + reduction.document + )).to.be.true; + }); + + it('clears a sole trailing-comma property without leaving invalid syntax', () => { + const source = 'const Example = component({ fill: Color.red, });'; + const document = parsed(source); + const reduction = reduce(document, ClearPropertyOverride, { + nodeId: document.root.id, + property: 'fill' + }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(projection.supported).to.be.true; + expect(projection.sourceAfter).equals('const Example = component({ });'); + }); + + it('renames static owner-layout references together with their target node', () => { + const source = `const Example = component({ + name: 'example', + layout: new TilingLayout({ + resizePolicies: [['child', { height: 'fixed', width: 'fill' }]] + }), + submorphs: [{ name: 'child' }] +});`; + const document = parsed(source); + const child = document.root.children[0]; + const reduction = reduce(document, RenameNode, { + nodeId: child.id, + name: 'renamed child' + }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(projection.supported).to.be.true; + expect(projection.changes).length(2); + expect(projection.sourceAfter).includes("name: \"renamed child\""); + expect(projection.sourceAfter) + .includes("resizePolicies: [[\"renamed child\", { height: 'fixed', width: 'fill' }]]"); + expect(componentDocumentsSemanticallyEqual( + projection.projectedDocument, + reduction.document + )).to.be.true; + }); + + it('renames a child under a non-referencing constraint layout', () => { + const source = `const Example = component({ + name: 'example', + layout: new ConstraintLayout({ + reactToSubmorphAnimations: false, + submorphSettings: [] + }), + submorphs: [add({ name: 'child' })] +});`; + const document = parsed(source); + const child = document.root.children[0]; + const reduction = reduce(document, RenameNode, { + nodeId: child.id, + name: 'renamed child' + }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(projection.supported, JSON.stringify(projection.diagnostics)).to.be.true; + expect(projection.changes).length(1); + expect(projection.sourceAfter).includes("name: \"renamed child\""); + expect(componentDocumentsSemanticallyEqual( + projection.projectedDocument, + reduction.document + )).to.be.true; + }); + + it('refreshes semantic layout models after replacing a layout expression', () => { + const source = `const Example = component({ + name: 'example', + layout: null, + submorphs: [{ name: 'child' }] +});`; + const document = parsed(source); + const reduction = reduce(document, SetOpaqueProperty, { + nodeId: document.root.id, + property: 'layout', + expression: `new TilingLayout({ + resizePolicies: [['child', { height: 'fixed', width: 'fill' }]] + })`, + requiredBindings: [componentImportBinding({ + kind: ComponentImportKind.NAMED, + moduleId: 'lively.morphic/layout.js', + imported: 'TilingLayout', + local: 'TilingLayout' + })] + }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(projection.supported).to.be.true; + expect(projection.sourceAfter) + .includes('import { TilingLayout } from "lively.morphic/layout.js";'); + expect(projection.projectedDocument.layoutModels).length(1); + expect(projection.projectedDocument.layoutModels[0].ownerId) + .equals(document.root.id); + expect(projection.projectedDocument.layoutModels[0].references[0].targetId) + .equals(document.root.children[0].id); + }); + + it('replaces an owner layout without rewriting its generated submorphs', () => { + const source = `const Example = component({ + name: 'example', + submorphs: [{ + name: 'date array', + layout: new TilingLayout({ spacing: 2 }), + submorphs: arr.range(1, 41).map(i => part(DateDefault, { name: 'day ' + i })) + }] +});`; + const parsedSource = parseComponentSource({ + source, moduleId, exportName: 'Example', componentId + }); + const document = parsedSource.document; + const dateArray = document.root.children[0]; + const reduction = reduce(document, SetOpaqueProperty, { + nodeId: dateArray.id, + property: 'layout', + expression: 'new TilingLayout({ spacing: 7, wrapSubmorphs: true })' + }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(parsedSource.supported).to.be.true; + expect(projection.supported, JSON.stringify(projection.diagnostics)).to.be.true; + expect(projection.sourceAfter) + .includes('layout: new TilingLayout({ spacing: 7, wrapSubmorphs: true })'); + expect(projection.sourceAfter) + .includes("submorphs: arr.range(1, 41).map(i => part(DateDefault, { name: 'day ' + i }))"); + }); + + it('rejects structural insertion into generated submorphs without changing source', () => { + const source = `const Example = component({ + name: 'example', + submorphs: [{ + name: 'date array', + submorphs: makeDates() + }] +});`; + const document = parsed(source); + const dateArray = document.root.children[0]; + const introduced = new ComponentNode({ + id: `${componentId}:generated-introduction`, + name: 'introduced', + provenance: localNodeProvenance() + }); + const reduction = reduce(document, IntroduceNode, { + parentId: dateArray.id, + node: introduced, + beforeId: null + }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(projection.supported).to.be.false; + expect(projection.sourceAfter).equals(source); + }); + + it('rejects rename projection through an unmodeled owner layout', () => { + const source = `const Example = component({ + name: 'example', + layout: new TilingLayout({ resizePolicies: policies }), + submorphs: [{ name: 'child' }] +});`; + const document = parsed(source); + const reduction = reduce(document, RenameNode, { + nodeId: document.root.children[0].id, + name: 'renamed child' + }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(projection.supported).to.be.false; + expect(projection.sourceAfter).equals(source); + expect(projection.diagnostics[0].kind).equals('missing-source-metadata'); + }); + + it('treats an explicit null owner layout as non-reference-bearing', () => { + const source = `const Example = component({ + name: 'example', + layout: null, + submorphs: [{ name: 'child' }] +});`; + const document = parsed(source); + const child = document.root.children[0]; + const renamed = reduce(document, RenameNode, { + nodeId: child.id, + name: 'renamed child' + }); + const renameProjection = projectComponentSource({ + source, + beforeDocument: document, + reduction: renamed + }); + + expect(renameProjection.supported).to.be.true; + expect(renameProjection.sourceAfter).includes('layout: null'); + expect(renameProjection.sourceAfter).includes('name: "renamed child"'); + + const removed = reduce(renameProjection.projectedDocument, RemoveNode, { + nodeId: child.id + }); + const removalProjection = projectComponentSource({ + source: renameProjection.sourceAfter, + beforeDocument: renameProjection.projectedDocument, + reduction: removed + }); + expect(removalProjection.supported).to.be.true; + expect(removalProjection.sourceAfter).includes('layout: null'); + expect(removalProjection.sourceAfter).not.includes('renamed child'); + }); + + it('treats a literal undefined owner layout as non-reference-bearing', () => { + const source = `const Example = component({ + name: 'example', + layout: undefined, + submorphs: [{ name: 'child' }] +});`; + const document = parsed(source); + const child = document.root.children[0]; + const renamed = reduce(document, RenameNode, { + nodeId: child.id, + name: 'renamed child' + }); + const renameProjection = projectComponentSource({ + source, + beforeDocument: document, + reduction: renamed + }); + + expect(renameProjection.supported).to.be.true; + expect(renameProjection.sourceAfter).includes('layout: undefined'); + expect(renameProjection.sourceAfter).includes('name: "renamed child"'); + + const removed = reduce(renameProjection.projectedDocument, RemoveNode, { + nodeId: child.id + }); + const removalProjection = projectComponentSource({ + source: renameProjection.sourceAfter, + beforeDocument: renameProjection.projectedDocument, + reduction: removed + }); + expect(removalProjection.supported).to.be.true; + expect(removalProjection.sourceAfter).includes('layout: undefined'); + expect(removalProjection.sourceAfter).not.includes('renamed child'); + }); + + it('removes a final local child without changing surviving node identities', () => { + const source = `const Example = component({ + name: 'example', + submorphs: [ + { name: 'first' }, + { name: 'last' } + ] +});`; + const document = parsed(source); + const last = document.root.children[1]; + const reduction = reduce(document, RemoveNode, { nodeId: last.id }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(projection.supported).to.be.true; + expect(projection.sourceAfter).includes("{ name: 'first' }"); + expect(projection.sourceAfter).not.includes("{ name: 'last' }"); + expect(componentDocumentsSemanticallyEqual( + projection.projectedDocument, + reduction.document + )).to.be.true; + }); + + it('retargets a surviving add anchor when removing its referenced sibling', () => { + const source = `const Example = component({ + name: 'example', + submorphs: [ + add({ name: 'dependent' }, 'anchor'), + { name: 'anchor' }, + { name: 'successor' } + ] +});`; + const document = parsed(source); + const dependent = document.root.children.find(({ name }) => name === 'dependent'); + const anchor = document.root.children.find(({ name }) => name === 'anchor'); + const successor = document.root.children.find(({ name }) => name === 'successor'); + const reduction = reduce(document, RemoveNode, { nodeId: anchor.id }); + const reducedDependent = reduction.document.root.children.find(({ name }) => + name === 'dependent'); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(reducedDependent.provenance.beforeId).equals(successor.id); + expect(projection.supported, JSON.stringify(projection.diagnostics)).to.be.true; + expect(projection.sourceAfter) + .includes('add({ name: \'dependent\' }, "successor")'); + expect(projection.sourceAfter).not.includes("name: 'anchor'"); + expect(componentDocumentsSemanticallyEqual( + projection.projectedDocument, + reduction.document + )).to.be.true; + expect(projection.projectedDocument.root.children[0].id).equals(dependent.id); + }); + + it('removes a local child and its static owner-layout resize policy atomically', () => { + const source = `const Example = component({ + name: 'example', + layout: new TilingLayout({ + resizePolicies: [ + ['first', { width: 'fixed' }], + ['middle', { width: 'fill' }], + ['last', { width: 'fixed' }] + ] + }), + submorphs: [ + { name: 'first' }, + { name: 'middle' }, + { name: 'last' } + ] +});`; + const document = parsed(source); + const middle = document.root.children[1]; + const reduction = reduce(document, RemoveNode, { nodeId: middle.id }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(projection.supported).to.be.true; + expect(projection.changes).length(2); + expect(projection.sourceAfter).not.includes("['middle'"); + expect(projection.sourceAfter).not.includes("{ name: 'middle' }"); + expect(projection.sourceAfter).includes("['first', { width: 'fixed' }]"); + expect(projection.sourceAfter).includes("['last', { width: 'fixed' }]"); + expect(componentDocumentsSemanticallyEqual( + projection.projectedDocument, + reduction.document + )).to.be.true; + }); + + it('removes the complete add-part expression rather than only its override object', () => { + const source = `const Example = component({ + name: 'example', + submorphs: [part(Card, { name: 'card' }), add(part(Button, { name: 'button' }), 'card')] +});`; + const document = parsed(source); + const addedPart = document.root.children.find(({ name }) => name === 'button'); + const reduction = reduce(document, RemoveNode, { nodeId: addedPart.id }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(projection.supported).to.be.true; + expect(projection.sourceAfter).includes("part(Card, { name: 'card' })"); + expect(projection.sourceAfter).not.includes('add(part(Button'); + expect(projection.sourceAfter).not.includes("'card')"); + expect(componentDocumentsSemanticallyEqual( + projection.projectedDocument, + reduction.document + )).to.be.true; + }); + + it('rewrites add wrappers when a part crosses a plain-subtree ownership boundary', () => { + const source = `const Example = component({ + name: 'example', + submorphs: [ + add(part(Card, { + name: 'card', + submorphs: [add(part(Button, { name: 'button' }))] + })), + add({ name: 'plain addition' }) + ] +});`; + const document = parsed(source); + const card = document.root.children.find(({ name }) => name === 'card'); + const button = card.children.find(({ name }) => name === 'button'); + const plainAddition = document.root.children.find(({ name }) => + name === 'plain addition'); + const movedIntoPlain = reduce(document, MoveNode, { + nodeId: button.id, + parentId: plainAddition.id, + beforeId: null + }); + const plainProjection = projectComponentSource({ + source, + beforeDocument: document, + reduction: movedIntoPlain + }); + + expect(plainProjection.supported).to.be.true; + expect(plainProjection.sourceAfter) + .includes("submorphs: [part(Button, { name: 'button' })]"); + expect(plainProjection.sourceAfter) + .not.includes("submorphs: [add(part(Button, { name: 'button' }))]"); + expect(componentDocumentsSemanticallyEqual( + plainProjection.projectedDocument, + movedIntoPlain.document + )).to.be.true; + + const movedBackIntoPart = reduce(plainProjection.projectedDocument, MoveNode, { + nodeId: button.id, + parentId: card.id, + beforeId: null + }); + const partProjection = projectComponentSource({ + source: plainProjection.sourceAfter, + beforeDocument: plainProjection.projectedDocument, + reduction: movedBackIntoPart + }); + + expect(partProjection.supported).to.be.true; + expect(partProjection.sourceAfter).includes( + "submorphs: [add(part(Button, { name: 'button' }))]" + ); + expect(componentDocumentsSemanticallyEqual( + partProjection.projectedDocument, + movedBackIntoPart.document + )).to.be.true; + }); + + it('projects an inherited cross-parent move as suppression plus materialization', () => { + const source = `const Example = component({ + name: 'example', + submorphs: [ + part(Card, { name: 'card', submorphs: [{ name: 'inherited' }] }), + part(Destination, { name: 'destination' }) + ] +});`; + const document = parsed(source); + const card = document.root.children[0]; + const destination = document.root.children[1]; + const inherited = card.children[0]; + const materialized = new ComponentNode({ + id: `${destination.id}.1`, + name: inherited.name, + provenance: addedNodeProvenance(), + partComponent: sourceComponentReference('Leaf'), + properties: { + layout: opaqueProperty('new TilingLayout({ spacing: 7 })') + } + }); + const reduction = reduce(document, MoveNode, { + nodeId: inherited.id, + parentId: destination.id, + beforeId: null, + inheritanceTransition: { + kind: ComponentMoveInheritanceTransitionKind.MATERIALIZE, + node: materialized + } + }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(reduction.semanticDelta.inheritanceTransition) + .equals(ComponentMoveInheritanceTransitionKind.MATERIALIZE); + expect(projection.supported, JSON.stringify(projection.diagnostics)).to.be.true; + expect(projection.sourceAfter).includes('import { add } from "lively.morphic";'); + expect(projection.sourceAfter).includes('without("inherited")'); + expect(projection.sourceAfter) + .includes('add(part(Leaf, { name: "inherited", layout: new TilingLayout({ spacing: 7 }) }))'); + expect(projection.projectedDocument.layoutModels.some(model => + model.ownerId === materialized.id && + model.expressionTemplate.includes('spacing: 7'))).to.be.true; + expect(projection.projectedDocument.root).deep.equals(reduction.document.root); + }); + + it('preserves nested suppressions while materializing an inherited subtree', () => { + const source = `const Example = component({ + name: 'example', + submorphs: [ + part(Card, { + name: 'card', + submorphs: [{ + name: 'inherited', + submorphs: [{ + name: 'nested', + submorphs: [without('hidden')] + }] + }] + }), + part(Destination, { name: 'destination' }) + ] +});`; + const document = parsed(source); + const card = document.root.children[0]; + const destination = document.root.children[1]; + const inherited = card.children[0]; + const nested = inherited.children[0]; + const hidden = nested.children[0]; + const materialized = new ComponentNode({ + id: `${destination.id}.1`, + name: inherited.name, + provenance: addedNodeProvenance(), + partComponent: sourceComponentReference('Leaf'), + children: [new ComponentNode({ + id: `${destination.id}.1.0`, + name: nested.name, + provenance: nested.provenance, + properties: nested.properties, + children: [new ComponentNode({ + id: `${destination.id}.1.0.0`, + name: hidden.name, + provenance: inheritedNodeProvenance({ + ...hidden.provenance, + baseName: hidden.name, + hasLocalOverrides: true + }), + properties: { + ...hidden.properties, + borderWidth: explicitProperty(2) + } + })] + })] + }); + const reduction = reduce(document, MoveNode, { + nodeId: inherited.id, + parentId: destination.id, + beforeId: null, + inheritanceTransition: { + kind: ComponentMoveInheritanceTransitionKind.MATERIALIZE, + node: materialized + } + }); + const projection = projectComponentSource({ + source, + beforeDocument: document, + reduction + }); + + expect(projection.supported, JSON.stringify(projection.diagnostics)).to.be.true; + expect(projection.sourceAfter).includes( + 'submorphs: [{ name: "hidden", borderWidth: 2 }, without("hidden")]' + ); + expect(componentDocumentsSemanticallyEqual( + projection.projectedDocument, + reduction.document + )).to.be.true; + }); + + it('appends a local node while preserving existing source-path identities', () => { + const source = `const Example = component({ + name: 'example', + submorphs: [{ name: 'first' }] +});`; + const document = parsed(source); + const introduced = new ComponentNode({ + id: `${componentId}:node:1`, + name: 'introduced', + provenance: localNodeProvenance(), + properties: { + fill: opaqueProperty('Color.green'), + borderWidth: opaqueProperty('2'), + opacity: explicitProperty(0.5) + } + }); + const reduction = reduce(document, IntroduceNode, { + parentId: document.root.id, + node: introduced, + beforeId: null, + requiredBindings: [componentImportBinding({ + kind: ComponentImportKind.NAMED, + moduleId: 'lively.graphics', + imported: 'Color', + local: 'Color' + })] + }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(projection.supported).to.be.true; + expect(projection.sourceAfter).includes('import { Color } from "lively.graphics";'); + expect(projection.sourceAfter).includes("{ name: 'first' }"); + expect(projection.sourceAfter) + .includes('{ name: "introduced", fill: Color.green, borderWidth: 2, opacity: 0.5 }'); + expect(componentDocumentsSemanticallyEqual( + projection.projectedDocument, + reduction.document + )).to.be.true; + }); + + it('preserves quoted opaque rich text across an unrelated introduction', () => { + const source = `const Example = component({ + name: 'example', + textAndAttributes: [ + 'rich \\'quoted\\' text', + { fontWeight: 'normal' }, + morph({ name: 'embedded "double"', fill: Color.blue }), + null + ] +});`; + const document = parsed(source); + const introduced = new ComponentNode({ + id: `${componentId}:node:0`, + name: 'introduced', + provenance: localNodeProvenance() + }); + const reduction = reduce(document, IntroduceNode, { + parentId: document.root.id, + node: introduced, + beforeId: null + }); + const projection = projectComponentSource({ + source, + beforeDocument: document, + reduction + }); + + expect(projection.supported, JSON.stringify(projection.diagnostics)).to.be.true; + expect(projection.sourceAfter).includes("'rich \\'quoted\\' text'"); + expect(projection.sourceAfter).includes("'embedded \"double\"'"); + expect(projection.sourceAfter).includes('name: "introduced"'); + }); + + it('projects a typed node introduction with its constructor import', () => { + const source = `const Example = component({ name: 'example' });`; + const document = parsed(source); + const introduced = new ComponentNode({ + id: `${componentId}:node:0`, + name: 'typed', + typeExpression: 'TypedMorph', + provenance: localNodeProvenance() + }); + const reduction = reduce(document, IntroduceNode, { + parentId: document.root.id, + node: introduced, + beforeId: null, + requiredBindings: [componentImportBinding({ + kind: ComponentImportKind.NAMED, + moduleId: 'local://widgets/typed-morph.js', + imported: 'TypedMorph', + local: 'TypedMorph' + })] + }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(projection.supported).to.be.true; + expect(projection.sourceAfter) + .includes('import { TypedMorph } from "local://widgets/typed-morph.js";'); + expect(projection.sourceAfter).includes('{ name: "typed", type: TypedMorph }'); + expect(componentDocumentsSemanticallyEqual( + projection.projectedDocument, + reduction.document + )).to.be.true; + }); + + it('projects a part introduction without flattening inherited structure', () => { + const source = `const Example = component({ name: 'example' });`; + const document = parsed(source); + const introduced = new ComponentNode({ + id: `${componentId}:node:0`, + name: 'button', + partComponent: sourceComponentReference('Button'), + provenance: localNodeProvenance(), + properties: { fill: explicitProperty('red') } + }); + const reduction = reduce(document, IntroduceNode, { + parentId: document.root.id, + node: introduced, + beforeId: null, + requiredBindings: [ + componentImportBinding({ + kind: ComponentImportKind.NAMED, + moduleId: 'local://widgets/button.cp.js', + imported: 'Button', + local: 'Button' + }), + componentImportBinding({ + kind: ComponentImportKind.NAMED, + moduleId: 'lively.morphic', + imported: 'part', + local: 'part' + }) + ] + }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(projection.supported).to.be.true; + expect(projection.sourceAfter) + .includes('import { Button } from "local://widgets/button.cp.js";'); + expect(projection.sourceAfter).includes('import { part } from "lively.morphic";'); + expect(projection.sourceAfter) + .includes('part(Button, { name: "button", fill: "red" })'); + expect(componentDocumentsSemanticallyEqual( + projection.projectedDocument, + reduction.document + )).to.be.true; + }); + + it('serializes inherited nested parts as overrides inside an introduced part', () => { + const baseDocument = new ComponentDocument({ + componentId: 'base', + moduleId: 'local://widgets/base.cp.js', + exportName: 'Base', + root: new ComponentNode({ + id: 'base-root', + name: 'base', + provenance: localNodeProvenance(), + children: [new ComponentNode({ + id: 'base-nested-part', + name: 'nested part', + provenance: localNodeProvenance(), + partComponent: sourceComponentReference('Leaf') + })] + }) + }); + const source = `const Example = component({ name: 'example' });`; + const document = parseComponentSource({ + source, + moduleId, + exportName: 'Example', + componentId, + resolveComponentDocument: ({ expression }) => + expression === 'Base' ? baseDocument : null + }).document; + const introduced = new ComponentNode({ + id: `${componentId}:node:0`, + name: 'base part', + partComponent: sourceComponentReference('Base'), + provenance: localNodeProvenance(), + children: [new ComponentNode({ + id: `${componentId}:node:0.0`, + name: 'nested part', + partComponent: sourceComponentReference('Leaf'), + provenance: inheritedNodeProvenance({ + hasLocalOverrides: true, + baseName: 'nested part' + }), + properties: { opacity: explicitProperty(0.5) } + })] + }); + const reduction = reduce(document, IntroduceNode, { + parentId: document.root.id, + node: introduced, + beforeId: null, + requiredBindings: [ + componentImportBinding({ + kind: ComponentImportKind.NAMED, + moduleId: 'local://widgets/base.cp.js', + imported: 'Base', + local: 'Base' + }), + componentImportBinding({ + kind: ComponentImportKind.NAMED, + moduleId: 'lively.morphic', + imported: 'part', + local: 'part' + }) + ] + }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(projection.supported).to.be.true; + expect(projection.sourceAfter) + .includes('submorphs: [{ name: "nested part", opacity: 0.5 }]'); + expect(projection.sourceAfter).not.includes('part(Leaf'); + expect(componentDocumentsSemanticallyEqual( + projection.projectedDocument, + reduction.document + )).to.be.true; + }); + + it('inserts a local node before an existing sibling without changing identities', () => { + const source = `const Example = component({ + name: 'example', + submorphs: [{ name: 'last' }] +});`; + const document = parsed(source); + const last = document.root.children[0]; + const introduced = new ComponentNode({ + id: `${componentId}:node:1`, + name: 'introduced', + provenance: localNodeProvenance() + }); + const reduction = reduce(document, IntroduceNode, { + parentId: document.root.id, + node: introduced, + beforeId: last.id + }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(projection.supported).to.be.true; + expect(projection.sourceAfter.indexOf('introduced')) + .below(projection.sourceAfter.indexOf('last')); + expect(projection.projectedDocument.root.children.map(({ id }) => id)) + .deep.equals([introduced.id, last.id]); + expect(componentDocumentsSemanticallyEqual( + projection.projectedDocument, + reduction.document + )).to.be.true; + }); + + it('preserves the order of additions sharing an inherited ordering anchor', () => { + const parentDocument = new ComponentDocument({ + componentId: 'parent', + moduleId: 'local://parent.cp.js', + exportName: 'Parent', + root: new ComponentNode({ + id: 'parent-root', + name: 'parent', + provenance: localNodeProvenance(), + children: [new ComponentNode({ + id: 'inherited-anchor', + name: 'anchor', + provenance: localNodeProvenance() + })] + }) + }); + const source = `const Example = component(Base, { + name: 'derived', + submorphs: [ + add({ name: 'first' }, 'anchor'), + { name: 'anchor', fill: 'red' }, + add({ name: 'second' }, 'anchor') + ] +});`; + const document = parseComponentSource({ + source, + moduleId, + exportName: 'Example', + componentId, + parentDocument + }).document; + const anchor = document.root.children.find(node => node.name === 'anchor'); + const introduced = new ComponentNode({ + id: `${componentId}:introduced`, + name: 'introduced', + provenance: addedNodeProvenance({ beforeId: anchor.id }) + }); + const reduction = reduce(document, IntroduceNode, { + parentId: document.root.id, + node: introduced, + beforeId: anchor.id + }); + const projection = projectComponentSource({ + source, + beforeDocument: document, + reduction + }); + + expect(projection.supported, JSON.stringify(projection.diagnostics)).to.be.true; + expect(projection.projectedDocument.root.children.map(node => node.name)) + .deep.equals(['first', 'second', 'introduced', 'anchor']); + expect(componentDocumentsSemanticallyEqual( + projection.projectedDocument, + reduction.document + )).to.be.true; + }); + + it('reorders local siblings while preserving identities and refreshed metadata', () => { + const source = `const Example = component({ + name: 'example', + submorphs: [ + { name: 'first', fill: 'red' }, + // Keep this positional comment during the source transform. + { name: 'second', fill: 'green' }, + { name: 'third', fill: 'blue' } + ] +});`; + const document = parsed(source); + const [first, second, third] = document.root.children; + const reordered = reduce(document, MoveNode, { + nodeId: third.id, + parentId: document.root.id, + beforeId: first.id + }); + const reorderProjection = projectComponentSource({ + source, + beforeDocument: document, + reduction: reordered + }); + + expect(reorderProjection.supported).to.be.true; + expect(reorderProjection.projectedDocument.root.children.map(({ id }) => id)) + .deep.equals([third.id, first.id, second.id]); + expect(reorderProjection.sourceAfter.indexOf("name: 'third'")) + .to.be.lessThan(reorderProjection.sourceAfter.indexOf("name: 'first'")); + expect(reorderProjection.sourceAfter) + .includes('// Keep this positional comment during the source transform.'); + + const propertyReduction = reduce(reorderProjection.projectedDocument, SetProperty, { + nodeId: third.id, + property: 'fill', + value: 'purple' + }); + const propertyProjection = projectComponentSource({ + source: reorderProjection.sourceAfter, + beforeDocument: reorderProjection.projectedDocument, + reduction: propertyReduction + }); + + expect(propertyProjection.supported).to.be.true; + expect(propertyProjection.sourceAfter).includes("name: 'third', fill: \"purple\""); + expect(propertyProjection.sourceAfter).includes("name: 'first', fill: 'red'"); + }); + + it('reparents a local subtree with stable identities and source expressions', () => { + const source = `const Example = component({ + name: 'example', + submorphs: [{ + name: 'source', + submorphs: [{ + name: 'moved', + fill: Color.red, + submorphs: [{ name: 'nested' }] + }] + }, { + name: 'destination', + submorphs: [{ name: 'first' }, { name: 'last' }] + }] +});`; + const document = parsed(source); + const [sourceParent, destination] = document.root.children; + const moved = sourceParent.children[0]; + const nested = moved.children[0]; + const reduction = reduce(document, MoveNode, { + nodeId: moved.id, + parentId: destination.id, + beforeId: destination.children[1].id + }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(projection.supported).to.be.true; + expect(projection.changes).to.have.length(2); + expect(projection.projectedDocument.root.children[0].children).deep.equals([]); + expect(projection.projectedDocument.root.children[1].children.map(({ id }) => id)) + .deep.equals([destination.children[0].id, moved.id, destination.children[1].id]); + expect(projection.projectedDocument.root.children[1].children[1].children[0].id) + .equals(nested.id); + expect(projection.sourceAfter).includes('fill: Color.red'); + + const propertyReduction = reduce(projection.projectedDocument, SetProperty, { + nodeId: moved.id, + property: 'opacity', + value: 0.5 + }); + const propertyProjection = projectComponentSource({ + source: projection.sourceAfter, + beforeDocument: projection.projectedDocument, + reduction: propertyReduction + }); + expect(propertyProjection.supported).to.be.true; + expect(propertyProjection.sourceAfter).includes('opacity: 0.5'); + }); + + it('rewrites added-node ordering anchors when reparenting', () => { + const source = `const Example = component({ + name: 'example', + submorphs: [part(Source, { + name: 'source', + submorphs: [add({ name: 'moved' }, 'source tail'), { name: 'source tail' }] + }), part(Destination, { + name: 'destination', + submorphs: [{ name: 'destination first' }, { name: 'destination tail' }] + })] +});`; + const document = parsed(source); + const [sourceParent, destination] = document.root.children; + const moved = sourceParent.children[0]; + const reduction = reduce(document, MoveNode, { + nodeId: moved.id, + parentId: destination.id, + beforeId: destination.children[1].id + }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(projection.supported).to.be.true; + expect(projection.sourceAfter).includes("add({ name: 'moved' }, \"destination tail\")"); + expect(projection.sourceAfter).not.includes("add({ name: 'moved' }, 'source tail')"); + expect(componentDocumentsSemanticallyEqual( + projection.projectedDocument, + reduction.document + )).to.be.true; + }); + + it('retargets an old-scope ordering dependant when its anchor is reparented', () => { + const source = `const Example = component({ + name: 'example', + submorphs: [part(Source, { + name: 'source', + submorphs: [ + add({ name: 'dependent' }, 'moved'), + add({ name: 'moved' }, 'source tail'), + { name: 'source tail' } + ] + }), part(Destination, { name: 'destination' })] +});`; + const document = parsed(source); + const [sourceParent, destination] = document.root.children; + const dependent = sourceParent.children.find(({ name }) => name === 'dependent'); + const moved = sourceParent.children.find(({ name }) => name === 'moved'); + const sourceTail = sourceParent.children.find(({ name }) => name === 'source tail'); + const reduction = reduce(document, MoveNode, { + nodeId: moved.id, + parentId: destination.id, + beforeId: null + }); + const projectedDependent = reduction.document.root.children[0].children + .find(({ name }) => name === dependent.name); + const projection = projectComponentSource({ + source, + beforeDocument: document, + reduction + }); + + expect(projectedDependent.provenance.beforeId).equals(sourceTail.id); + expect(projection.supported, JSON.stringify(projection.diagnostics)).to.be.true; + expect(projection.sourceAfter) + .includes('add({ name: \'dependent\' }, "source tail")'); + expect(componentDocumentsSemanticallyEqual( + projection.projectedDocument, + reduction.document + )).to.be.true; + const restored = reduceComponent(reduction.document, reduction.inverseCommand); + expect(restored.document.root).deep.equals(document.root); + }); + + it('rewrites an added-node ordering anchor when moving before an inherited sibling', () => { + const source = `const Example = component({ + name: 'example', + submorphs: [part(Card, { + name: 'card', + submorphs: [{ name: 'inherited' }, add({ name: 'moved' })] + })] +});`; + const document = parsed(source); + const card = document.root.children[0]; + const inherited = card.children.find(({ name }) => name === 'inherited'); + const moved = card.children.find(({ name }) => name === 'moved'); + const reduction = reduce(document, MoveNode, { + nodeId: moved.id, + parentId: card.id, + beforeId: inherited.id + }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(projection.supported).to.be.true; + expect(projection.sourceAfter).includes('add({ name: \'moved\' }, "inherited")'); + expect(projection.projectedDocument.root.children[0].children.map(({ name }) => name)) + .deep.equals(['moved', 'inherited']); + expect(componentDocumentsSemanticallyEqual( + projection.projectedDocument, + reduction.document + )).to.be.true; + }); + + it('projects inherited suppression and restoration through without markers', () => { + const parentDocument = new ComponentDocument({ + componentId: 'parent', + moduleId: 'local://parent.cp.js', + exportName: 'Parent', + root: new ComponentNode({ + id: 'parent-root', name: 'parent', provenance: localNodeProvenance(), + children: [new ComponentNode({ + id: 'inherited-child', + name: 'inherited child', + provenance: localNodeProvenance() + })] + }) + }); + const source = `const Example = component(Base, { name: 'derived' });`; + const document = parseComponentSource({ + source, moduleId, exportName: 'Example', componentId, parentDocument + }).document; + const inherited = document.root.children[0]; + expect(inherited.provenance).deep.equals(inheritedNodeProvenance({ + baseName: 'inherited child' + })); + const suppressed = reduce(document, SuppressInheritedNode, { nodeId: inherited.id }); + const suppressionProjection = projectComponentSource({ + source, beforeDocument: document, reduction: suppressed + }); + + expect(suppressionProjection.supported).to.be.true; + expect(suppressionProjection.sourceAfter) + .includes('import { without } from "lively.morphic/components/core.js";'); + expect(suppressionProjection.sourceAfter).includes('without("inherited child")'); + expect(suppressionProjection.projectedDocument.root.children[0].provenance.suppressed) + .to.be.true; + + const restored = reduce( + suppressionProjection.projectedDocument, + RestoreInheritedNode, + { + nodeId: inherited.id, + parentId: suppressionProjection.projectedDocument.root.id, + beforeId: null + } + ); + const restorationProjection = projectComponentSource({ + source: suppressionProjection.sourceAfter, + beforeDocument: suppressionProjection.projectedDocument, + reduction: restored + }); + expect(restorationProjection.supported).to.be.true; + expect(restorationProjection.sourceAfter).not.includes('without("inherited child")'); + expect(restorationProjection.projectedDocument.root.children[0].provenance.suppressed) + .to.be.false; + }); + + it('consolidates duplicate without markers when restoring an inherited node', () => { + const parentDocument = new ComponentDocument({ + componentId: 'parent', + moduleId: 'local://parent.cp.js', + exportName: 'Parent', + root: new ComponentNode({ + id: 'parent-root', + name: 'parent', + provenance: localNodeProvenance(), + children: [new ComponentNode({ + id: 'inherited-child', + name: 'inherited child', + provenance: localNodeProvenance() + })] + }) + }); + const source = `const Example = component(Base, { + name: 'derived', + submorphs: [ + without('inherited child'), + without("inherited child") + ] +});`; + const document = parseComponentSource({ + source, + moduleId, + exportName: 'Example', + componentId, + parentDocument + }).document; + const inherited = document.root.children[0]; + expect(document.sourceMetadata.suppressionLocationLists[inherited.id]) + .to.have.length(2); + const restored = reduce(document, RestoreInheritedNode, { + nodeId: inherited.id, + parentId: document.root.id, + beforeId: null + }); + const projection = projectComponentSource({ + source, + beforeDocument: document, + reduction: restored + }); + + expect(projection.supported, JSON.stringify(projection.diagnostics)).to.be.true; + expect(projection.sourceAfter).not.match(/without\s*\(/); + expect(projection.projectedDocument.root.children[0].provenance.suppressed) + .to.be.false; + }); + + it('projects suppression inside a resolved part override and reparses its identities', () => { + const cardDocument = new ComponentDocument({ + componentId: 'card', + moduleId: 'local://card.cp.js', + exportName: 'Card', + root: new ComponentNode({ + id: 'card-root', name: 'card', provenance: localNodeProvenance(), + children: [new ComponentNode({ + id: 'card-label', name: 'label', provenance: localNodeProvenance(), + children: [new ComponentNode({ + id: 'card-icon', name: 'icon', provenance: localNodeProvenance() + })] + })] + }) + }); + const source = `const Example = component({ + name: 'example', + submorphs: [part(Card, { + name: 'card', + submorphs: [{ name: 'label', submorphs: [] }] + })] +});`; + const document = parseComponentSource({ + source, + moduleId, + exportName: 'Example', + componentId, + resolveComponentDocument: ({ expression }) => + expression === 'Card' ? cardDocument : null + }).document; + const label = document.root.children[0].children[0]; + const icon = label.children[0]; + const suppressed = reduce(document, SuppressInheritedNode, { nodeId: icon.id }); + const projection = projectComponentSource({ + source, + beforeDocument: document, + reduction: suppressed + }); + + expect(projection.supported).to.be.true; + expect(projection.sourceAfter).includes('submorphs: [without("icon")]'); + expect(projection.projectedDocument.root.children[0].children[0].id).equals(label.id); + expect(projection.projectedDocument.root.children[0].children[0].children[0].id) + .equals(icon.id); + expect(projection.projectedDocument.root.children[0].children[0].children[0] + .provenance.suppressed).to.be.true; + expect(componentDocumentsSemanticallyEqual( + projection.projectedDocument, + suppressed.document + )).to.be.true; + }); + + it('reparents a local node and removes its former owner layout policy', () => { + const source = `const Example = component({ + name: 'example', + submorphs: [{ + name: 'source', + layout: new TilingLayout({ + resizePolicies: [['moved', { width: 'fill' }]] + }), + submorphs: [{ name: 'moved' }] + }, { + name: 'destination' + }] +});`; + const document = parsed(source); + const sourceNode = document.root.children[0]; + const destination = document.root.children[1]; + const movedNode = sourceNode.children[0]; + const reduction = reduce(document, MoveNode, { + nodeId: movedNode.id, + parentId: destination.id, + beforeId: null + }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(projection.supported).to.be.true; + expect(projection.changes).length(3); + expect(projection.sourceAfter).includes('resizePolicies: []'); + expect(projection.sourceAfter).not.includes("['moved'"); + expect(componentDocumentsSemanticallyEqual( + projection.projectedDocument, + reduction.document + )).to.be.true; + }); + + it('removes a sole child from a trailing-comma submorph list', () => { + const source = `const Example = component({ + name: 'example', + submorphs: [{ name: 'only' },] +});`; + const document = parsed(source); + const reduction = reduce(document, RemoveNode, { + nodeId: document.root.children[0].id + }); + const projection = projectComponentSource({ source, beforeDocument: document, reduction }); + + expect(projection.supported).to.be.true; + expect(projection.sourceAfter).includes('submorphs: []'); + }); +}); diff --git a/lively.ide/tests/studio/fill-control-projection-test.js b/lively.ide/tests/studio/fill-control-projection-test.js new file mode 100644 index 0000000000..890cc97113 --- /dev/null +++ b/lively.ide/tests/studio/fill-control-projection-test.js @@ -0,0 +1,63 @@ +/* global describe, it */ +import { expect } from 'mocha-es6'; +import { FillControlModel } from '../../studio/controls/fill.cp.js'; + +describe('fill control projectional commands', () => { + it('uses a component command without performing a second mutation', () => { + const target = { + owner: null, + sizeToAspectRatio: false, + _changeTracker: { + tracksMorph: () => true, + setProperty: ({ value }) => { + target.sizeToAspectRatio = value; + return Object.freeze({ committed: true }); + } + }, + withMetaDo: () => { throw new Error('unexpected second mutation'); } + }; + FillControlModel.prototype.aspectRatioChecked.call({ targetMorph: target }, true); + + expect(target.sizeToAspectRatio).to.be.true; + }); + + it('performs an ordinary mutation when no component command target exists', () => { + const metadata = []; + const target = { + owner: null, + sizeToAspectRatio: false, + withMetaDo: (meta, callback) => { + metadata.push(meta); + callback(); + } + }; + FillControlModel.prototype.aspectRatioChecked.call({ targetMorph: target }, true); + + expect(target.sizeToAspectRatio).to.be.true; + expect(metadata).deep.equals([]); + }); + + it('uses a serializer-backed component command for fill colors', () => { + const color = { isColor: true }; + const target = { + owner: null, + fill: null, + _changeTracker: { + tracksMorph: () => true, + setProperty: options => { + expect(options).deep.include({ target, property: 'fill', value: color }); + target.fill = options.value; + return Object.freeze({ committed: true }); + } + }, + withMetaDo: () => { throw new Error('unexpected second mutation'); } + }; + const model = { + targetMorph: target, + ui: { fillColorInput: { colorValue: color } } + }; + + FillControlModel.prototype.confirm.call(model); + expect(target.fill).equals(color); + }); +}); diff --git a/lively.ide/text/rich-text-commands.js b/lively.ide/text/rich-text-commands.js index 8945b82440..4b44e531b7 100644 --- a/lively.ide/text/rich-text-commands.js +++ b/lively.ide/text/rich-text-commands.js @@ -133,17 +133,14 @@ export const interactiveCommands = [ name: 'change font', scrollCursorIntoView: false, exec: async function (morph) { - morph.withMetaDo({ reconcileChanges: true }, async () => { - const fonts = availableFonts().map(font => font.name); - - const res = await $world.listPrompt('choose font', fonts, { - requester: morph, - preselect: fonts.indexOf(morph.fontFamily), - historyId: 'lively.morpic/text-change-font-hist' - }); - - if (res.status !== 'accepted') return false; - + const fonts = availableFonts().map(font => font.name); + const res = await $world.listPrompt('choose font', fonts, { + requester: morph, + preselect: fonts.indexOf(morph.fontFamily), + historyId: 'lively.morpic/text-change-font-hist' + }); + if (res.status !== 'accepted') return false; + return morph.withMetaDo({ reconcileChanges: true }, () => { morph.fontFamily = res.selected[0]; return true; }); @@ -154,17 +151,17 @@ export const interactiveCommands = [ name: 'set link of selection', scrollCursorIntoView: false, exec: async function (morph, args = {}) { - let link; - morph.withMetaDo({ reconcileChanges: true }, async () => { - if (!args.hasOwnProperty('link')) { - const sel = morph.selection; - const { link: oldLink } = morph.getStyleInRange(sel); - link = await morph.world().prompt('Set link', { - input: oldLink || 'https://', - historyId: 'lively.morphic-rich-text-link-hist' - }); - if (!link) return; - } + const sel = morph.selection; + let link = args.link; + if (!args.hasOwnProperty('link')) { + const { link: oldLink } = morph.getStyleInRange(sel); + link = await morph.world().prompt('Set link', { + input: oldLink || 'https://', + historyId: 'lively.morphic-rich-text-link-hist' + }); + if (!link) return; + } + return morph.withMetaDo({ reconcileChanges: true }, () => { morph.undoManager.group(); morph.setStyleInRange({ link: link || undefined }, sel); morph.undoManager.group(); @@ -211,7 +208,7 @@ export const interactiveCommands = [ name: 'reset text style', scrollCursorIntoView: false, exec: function (morph, args = {}) { - morph.withMetaDo({ reconcileChanges: true }, async () => { + return morph.withMetaDo({ reconcileChanges: true }, () => { morph.undoManager.group(); const range = !args.onlySelection && morph.selection.isEmpty() ? morph.documentRange diff --git a/lively.lang/object.js b/lively.lang/object.js index 2abc758c6d..8208fc1f44 100644 --- a/lively.lang/object.js +++ b/lively.lang/object.js @@ -25,7 +25,8 @@ function print (object, quote = '\"') { if (object && Array.isArray(object)) { return '[' + object.map(print) + ']'; } if (typeof object !== 'string') { return String(object); } let result = String(object); - result = result.replace(/\n/g, '\\n\\\n'); + result = result.replace(/\\/g, '\\\\'); + result = result.replace(/\n/g, '\\n'); result = result.replace(/(")/g, '\\$1'); result = result.replace(/(')/g, '\\$1'); result = quote + result + quote; diff --git a/lively.lang/tests/object-test.js b/lively.lang/tests/object-test.js index 04bfa2c930..710bfc3c33 100644 --- a/lively.lang/tests/object-test.js +++ b/lively.lang/tests/object-test.js @@ -224,6 +224,12 @@ describe('object', function () { '}'); }); + it('escapes backslashes in string literals', function () { + const value = 'path\\backslash\nnext line'; + const inspected = inspect({ value }, { quote: "'" }); + expect(Function(`return (${inspected})`)().value).to.equal(value); // eslint-disable-line no-new-func + }); + it('observes maxDepth when printing', function () { expect(inspect(obj1, { maxDepth: 1 })).to.equal( '{\n' + diff --git a/lively.modules/src/packages/configuration.js b/lively.modules/src/packages/configuration.js index 7d7fd6d883..46f3111d7b 100644 --- a/lively.modules/src/packages/configuration.js +++ b/lively.modules/src/packages/configuration.js @@ -64,6 +64,7 @@ export default class PackageConfiguration { pkg.version = version; pkg.config = config; pkg._name = name; + pkg.systemjs = config.systemjs; pkg.mergeWithConfig(packageInSystem); return livelyConfig ? this.applyLivelyConfig(livelyConfig) : { subPackages: [] }; diff --git a/lively.modules/src/packages/package.js b/lively.modules/src/packages/package.js index ba6a00d0fc..d72b50441f 100644 --- a/lively.modules/src/packages/package.js +++ b/lively.modules/src/packages/package.js @@ -471,6 +471,12 @@ class Package { let covered = registry.coversDirectory(url); this.remove(opts); + // Re-registration reads the full package.json again. Do not merge it with + // fields from the previous config, since removed mappings and import maps + // must actually disappear on reload. + this.setConfig({}); + this.config = {}; + this.map = {}; registry.addPackageAt(url, covered || 'devPackageDirs', { [url]: this }); return this.import(); } diff --git a/lively.modules/tests/package-test.js b/lively.modules/tests/package-test.js index f862ec3de0..b8e11489aa 100644 --- a/lively.modules/tests/package-test.js +++ b/lively.modules/tests/package-test.js @@ -53,6 +53,16 @@ describe('package loading', function () { // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- describe('basics', () => { + it('retains a package-scoped import map after registration', async () => { + const importMap = { imports: { dependency: 'https://example.com/dependency.js' } }; + await resource(project1aDir + '.cachedImportMap.json').writeJson(importMap); + + const pkg = await ensurePackage(S, project1aDir); + await pkg.register(); + + expect(pkg.systemjs.importMap).to.eql(importMap); + }); + it('loads package configs from encoded file URLs before the transpiler is configured', async () => { if (!System.get('@system-env').node) return; @@ -415,6 +425,25 @@ describe('package loading', function () { }); describe('reload', () => { + it('drops configuration fields removed from package.json', async () => { + const packageConfig = resource(project1aDir + 'package.json'); + const pkg = await ensurePackage(S, project1aDir); + await packageConfig.writeJson({ + ...JSON.parse(project1a['package.json']), + systemjs: { map: { dependency: 'https://example.com/dependency.js' } } + }); + await pkg.reload(); + expect(pkg.systemjs.map).to.have.property('dependency'); + expect(pkg.map).to.have.property('dependency'); + + await packageConfig.write(project1a['package.json']); + await pkg.reload(); + + expect(pkg.systemjs).to.equal(undefined); + expect(pkg.runtimeConfig).to.not.have.property('systemjs'); + expect(pkg.map).to.not.have.property('dependency'); + }); + it('of package in devPackageDirs', async () => { let registry = PackageRegistry.ofSystem(S); let pkg = await ensurePackage(S, project1aDir); diff --git a/lively.morphic/changes.js b/lively.morphic/changes.js index 5f929f3565..94362082bb 100644 --- a/lively.morphic/changes.js +++ b/lively.morphic/changes.js @@ -2,6 +2,49 @@ /* global WeakMap */ import { arr, obj } from 'lively.lang'; import { signal } from 'lively.bindings'; +import { MorphicChangeSet } from './changes/change-set.js'; +import { MorphicReplayDirection } from './changes/manager.js'; +import { + MorphicAttachmentKind, + MorphicValueSemantics, + MoveMorph, + SetMorphProperty, + attachedMorph, + detachedMorph +} from './changes/operations.js'; + +function isPromise (value) { + return value && typeof value.then === 'function'; +} + +function leafChangesOf (change) { + return change.changes?.length + ? change.changes.flatMap(leafChangesOf) + : [change]; +} + +function committedChangeContext (changes, committedChange = null) { + const targets = new Map(); + changes.forEach(change => { + const candidates = [ + change.target, + change.morph, + ...(change.owners || []), + ...(change.args || []).filter(arg => arg?.isMorph) + ]; + candidates.forEach(target => { + while (target) { + if (target.id) targets.set(target.id, target); + target = target.owner; + } + }); + }); + return Object.freeze({ + legacyChanges: Object.freeze(changes.slice()), + committedChange, + resolveMorph: id => targets.get(id) + }); +} function signalBindings (obj, name, change) { // optimized lively.bindings.signal @@ -65,22 +108,56 @@ export class GroupChange extends Change { export class ValueChange extends Change { get type () { return 'setter'; } - constructor (target, prop, value, meta) { + constructor (target, prop, value, meta, valuePolicy = {}) { super(target); + const prevValue = target._morphicState[prop]; this.prop = prop; this.value = value; - this.prevValue = null; + this.prevValue = prevValue; this.meta = meta; + this.operation = new SetMorphProperty({ + targetId: target.id, + property: prop, + before: prevValue, + after: value, + metadata: meta, + ...valuePolicy + }); + } + + operationContext () { + const { target } = this; + return { + resolveMorph: id => id === target.id ? target : null, + setMorphProperty: (resolvedTarget, property, value) => { + if (property in resolvedTarget) resolvedTarget[property] = value; + else resolvedTarget.setProperty(property, value); + }, + // Legacy undo has no conflict handling. Preserve that behavior while + // exposing exact preconditions to the transaction kernel. + checkPreconditions: false + }; + } + + applyOperation (operation, replayDirection) { + const { target, meta } = this; + const replayMeta = replayDirection + ? { + ...meta, + originalOrigin: meta.origin, + origin: replayDirection, + replayDirection + } + : meta; + return target.withMetaDo(replayMeta, () => operation.apply(this.operationContext())); } apply () { - const { target, prop, value } = this; - target[prop] = value; + this.applyOperation(this.operation, MorphicReplayDirection.REDO); } reverseApply () { - const { target, prop, prevValue } = this; - target[prop] = prevValue; + this.applyOperation(this.operation.invert(), MorphicReplayDirection.UNDO); } } @@ -97,16 +174,106 @@ export class MethodCallChange extends GroupChange { apply () { const { target, selector, args } = this; - target[selector].apply(target, args); + target.withMetaDo({ + ...this.meta, + originalOrigin: this.meta.origin, + origin: MorphicReplayDirection.REDO, + replayDirection: MorphicReplayDirection.REDO + }, () => target[selector].apply(target, args)); } reverseApply () { if (!this.undo) return; - if (typeof this.undo === 'function') this.undo(); - else { - const { target, selector, args } = this.undo; - target[selector].apply(target, args); - } + this.target.withMetaDo({ + ...this.meta, + originalOrigin: this.meta.origin, + origin: MorphicReplayDirection.UNDO, + replayDirection: MorphicReplayDirection.UNDO + }, () => { + if (typeof this.undo === 'function') this.undo(); + else { + const { target, selector, args } = this.undo; + target[selector].apply(target, args); + } + }); + } +} + +function morphAttachment (morph) { + const owner = morph.owner; + return owner + ? attachedMorph({ + ownerId: owner.id, + index: owner.submorphs.indexOf(morph), + transform: morph.getTransform().copy() + }) + : detachedMorph(); +} + +export class StructuralChange extends Change { + get type () { return 'method-call'; } + + constructor ({ target, morph, selector, args, operation, owners, meta }) { + super(target); + this.morph = morph; + this.selector = selector; + this.args = args; + this.operation = operation; + this.owners = owners; + this.meta = meta; + } + + operationContext () { + const { morph, owners } = this; + const targets = new Map([[morph.id, morph]]); + owners.forEach(owner => owner && targets.set(owner.id, owner)); + return { + resolveMorph: id => targets.get(id), + validateMoveMorph: (movedMorph, from, to) => { + const actual = morphAttachment(movedMorph); + if (actual.kind !== from.kind || + actual.ownerId !== from.ownerId || + actual.index !== from.index) { + throw new Error(`Stale structural change for ${movedMorph.id}`); + } + if (to.kind === MorphicAttachmentKind.ATTACHED) { + const owner = targets.get(to.ownerId); + if (movedMorph === owner || movedMorph.isAncestorOf(owner)) { + throw new Error('MoveMorph cannot create an ownership cycle'); + } + } + }, + moveMorph: (movedMorph, from, to) => { + if (to.kind === MorphicAttachmentKind.DETACHED) { + movedMorph.remove(); + return; + } + const owner = targets.get(to.ownerId); + let insertionIndex = to.index; + const currentIndex = owner.submorphs.indexOf(movedMorph); + if (currentIndex > -1 && currentIndex < insertionIndex) insertionIndex++; + owner.addMorphAt(movedMorph, insertionIndex); + const transform = to.transform; + if (transform && !obj.equals(movedMorph.getTransform(), transform)) { + movedMorph.dontRecordChangesWhile(() => movedMorph.setTransform(transform.copy())); + } + } + }; + } + + applyOperation (operation, replayDirection) { + const replayMeta = { + ...this.meta, + originalOrigin: this.meta.origin, + origin: replayDirection, + replayDirection + }; + return this.target.withMetaDo(replayMeta, () => operation.apply(this.operationContext())); + } + + apply () { this.applyOperation(this.operation, MorphicReplayDirection.REDO); } + reverseApply () { + this.applyOperation(this.operation.invert(), MorphicReplayDirection.UNDO); } } @@ -118,7 +285,9 @@ export class ChangeManager { reset () { this.changes = []; this.changeRecordedListeners = []; + this.committedChangeListeners = []; this.revision = 0; + this.commitCounter = 0; this.changeRecordersPerMorph = new WeakMap(); this.changeRecorders = {}; @@ -126,6 +295,7 @@ export class ChangeManager { this.changeGroupStack = []; this.defaultMeta = {}; this.metaStack = []; + this.propertyValuePolicies = new Map(); } changesFor (morph) { return this.changes.filter(c => c.target === morph); } @@ -141,18 +311,48 @@ export class ChangeManager { let res; try { res = doFn(morph); + if (isPromise(res)) { + throw new Error('withMetaDo callbacks must be synchronous'); + } } finally { this.metaStack.pop(); this.defaultMeta = arr.last(this.metaStack) || {}; - return res; } + return res; } addValueChange (morph, prop, value, meta) { - const change = new ValueChange(morph, prop, value, { ...this.defaultMeta, ...meta }); + const valuePolicy = this.propertyValuePolicies.get(prop) || {}; + const change = new ValueChange( + morph, + prop, + value, + { ...this.defaultMeta, ...meta }, + valuePolicy + ); return this._record(morph, change); } + setPropertyValuePolicy (property, policy = {}) { + if (typeof property !== 'string' || !property) { + throw new Error('Property value policies require a property name'); + } + if (!policy || typeof policy !== 'object') { + throw new Error('Property value policies require a policy object'); + } + if (policy.valueSemantics && + !Object.values(MorphicValueSemantics).includes(policy.valueSemantics)) { + throw new Error(`Unknown morphic property value semantics: ${policy.valueSemantics}`); + } + this.propertyValuePolicies.set(property, Object.freeze({ ...policy })); + return this; + } + + removePropertyValuePolicy (property) { + this.propertyValuePolicies.delete(property); + return this; + } + addMethodCallChangeDoing (spec, morph, doFn) { let { target, selector, args, undo, meta = {} } = spec; if (!undo) undo = () => console.warn(`No undo recorded for ${target}.${selector}`); @@ -161,11 +361,36 @@ export class ChangeManager { return change; } + addStructuralChangeDoing (spec, targetMorph, doFn) { + const { morph, selector, args, meta = {} } = spec; + const fromOwner = morph.owner; + const from = morphAttachment(morph); + this.dontRecordChangesWhile(targetMorph, doFn); + const toOwner = morph.owner; + const to = morphAttachment(morph); + const changeMeta = { ...this.defaultMeta, ...meta }; + const operation = new MoveMorph({ + morphId: morph.id, + from, + to, + metadata: changeMeta + }); + const change = new StructuralChange({ + target: targetMorph, + morph, + selector, + args, + operation, + owners: [fromOwner, toOwner], + meta: changeMeta + }); + return this._record(targetMorph, change); + } + _record (morph, change) { // FIXME signal(this, 'changeRecorded', change); if (change.hasOwnProperty('value')) { - change.prevValue = morph._morphicState[change.prop]; morph._morphicState[change.prop] = change.value; } @@ -191,6 +416,7 @@ export class ChangeManager { this.changes.push(change); morph._rev = ++this.revision; this.informChangeListeners(change); + this.informCommittedChangeListeners(change); } informMorph(this, change, morph); @@ -231,6 +457,15 @@ export class ChangeManager { arr.pushIfNotIncluded(this.changeRecordedListeners, listenFn); } + addCommittedChangeListener (listenFn) { + arr.pushIfNotIncluded(this.committedChangeListeners, listenFn); + return listenFn; + } + + removeCommittedChangeListener (listenFn) { + arr.remove(this.committedChangeListeners, listenFn); + } + removeChangeListener (listenFn) { arr.remove(this.changeRecordedListeners, listenFn); } @@ -240,6 +475,48 @@ export class ChangeManager { this.changeRecordedListeners.forEach(fn => fn(change)); } + informCommittedChangeListeners (change) { + const legacyChanges = leafChangesOf(change); + const normalizedTextReplacement = change.selector === 'replace' && + change.target?.isText && + change.meta?.partOfTextAndAttributesAssignment !== true && + Array.isArray(change.meta?.prevTextAndAttributes); + let operations = legacyChanges + .map(legacyChange => legacyChange.operation) + .filter(Boolean); + if (normalizedTextReplacement) { + operations = [new SetMorphProperty({ + targetId: change.target.id, + property: 'textAndAttributes', + before: change.meta.prevTextAndAttributes, + after: change.target.textAndAttributes, + metadata: { + ...change.meta, + acceptAlreadyApplied: true, + textReplacement: true + } + })]; + } + if (!operations.length) return null; + const meta = { ...(change.meta || operations[0].metadata) }; + const changeSet = new MorphicChangeSet({ + id: `legacy-morphic-change-${this.commitCounter++}`, + label: change.selector || change.prop || change.type, + origin: meta.origin || 'user', + undoable: meta.undoable !== false, + operations, + metadata: meta + }); + const context = committedChangeContext( + normalizedTextReplacement + ? [change] + : legacyChanges, + change + ); + this.committedChangeListeners.slice().forEach(listener => listener(changeSet, context)); + return changeSet; + } + recordChangesStart (optFilter, optName = '') { // change recorder is a change listener that is identified by id diff --git a/lively.morphic/changes/change-set.js b/lively.morphic/changes/change-set.js new file mode 100644 index 0000000000..06ff1df33d --- /dev/null +++ b/lively.morphic/changes/change-set.js @@ -0,0 +1,88 @@ +function validateOperation (operation) { + if (!operation || typeof operation.validate !== 'function' || + typeof operation.apply !== 'function' || typeof operation.invert !== 'function') { + throw new Error('MorphicChangeSet entries must be reversible morphic operations'); + } +} + +export class MorphicRollbackError extends Error { + constructor (message, cause, rollbackErrors) { + super(message); + this.name = 'MorphicRollbackError'; + this.cause = cause; + this.rollbackErrors = rollbackErrors; + } +} + +export function rollbackOperations (operations, context) { + const rollbackErrors = []; + for (const operation of operations.slice().reverse()) { + try { + const inverse = operation.invert(); + inverse.validate(context); + inverse.apply(context); + } catch (error) { + rollbackErrors.push(error); + } + } + return rollbackErrors; +} + +export class MorphicChangeSet { + constructor ({ + id, + label = '', + origin = 'user', + undoable = true, + operations = [], + metadata = {} + }) { + if (typeof id !== 'string' || !id) throw new Error('MorphicChangeSet requires an id'); + operations.forEach(validateOperation); + this.id = id; + this.label = label; + this.origin = origin; + this.undoable = !!undoable; + this.operations = Object.freeze(operations.slice()); + this.metadata = Object.freeze({ ...metadata }); + Object.freeze(this); + } + + validate (context) { + this.operations.forEach(operation => operation.validate(context)); + return this; + } + + apply (context) { + this.validate(context); + const applied = []; + try { + for (const operation of this.operations) { + operation.apply(context); + applied.push(operation); + } + } catch (error) { + const rollbackErrors = rollbackOperations(applied, context); + if (rollbackErrors.length) { + throw new MorphicRollbackError( + `Failed to apply ${this.id} and to roll it back completely`, + error, + rollbackErrors + ); + } + throw error; + } + return this; + } + + invert ({ id = `${this.id}:inverse`, origin = this.origin, metadata = {} } = {}) { + return new MorphicChangeSet({ + id, + label: this.label, + origin, + undoable: this.undoable, + operations: this.operations.slice().reverse().map(operation => operation.invert()), + metadata: { ...this.metadata, ...metadata, inverseOf: this.id } + }); + } +} diff --git a/lively.morphic/changes/index.js b/lively.morphic/changes/index.js new file mode 100644 index 0000000000..33f67a8547 --- /dev/null +++ b/lively.morphic/changes/index.js @@ -0,0 +1,17 @@ +export { + MorphicOperationKind, + MorphicAttachmentKind, + MorphicValueSemantics, + MorphicOperation, + SetMorphProperty, + MoveMorph, + attachedMorph, + detachedMorph, + CustomOperation +} from './operations.js'; +export { + MorphicChangeSet, + MorphicRollbackError +} from './change-set.js'; +export { MorphicTransaction } from './transaction.js'; +export { MorphicReplayDirection, MorphicTransactionManager } from './manager.js'; diff --git a/lively.morphic/changes/manager.js b/lively.morphic/changes/manager.js new file mode 100644 index 0000000000..eb89ef922b --- /dev/null +++ b/lively.morphic/changes/manager.js @@ -0,0 +1,91 @@ +import { MorphicChangeSet } from './change-set.js'; +import { MorphicTransaction } from './transaction.js'; + +function isPromise (value) { + return value && typeof value.then === 'function'; +} + +export const MorphicReplayDirection = Object.freeze({ + UNDO: 'undo', + REDO: 'redo' +}); + +export class MorphicTransactionManager { + constructor (context) { + this.context = context; + this.activeTransaction = null; + this.listeners = []; + this.transactionCounter = 0; + } + + nextId () { + return `morphic-transaction-${this.transactionCounter++}`; + } + + addCommitListener (listener) { + if (!this.listeners.includes(listener)) this.listeners.push(listener); + return listener; + } + + removeCommitListener (listener) { + const index = this.listeners.indexOf(listener); + if (index > -1) this.listeners.splice(index, 1); + } + + notifyCommitted (changeSet) { + this.listeners.slice().forEach(listener => listener(changeSet)); + } + + transaction (options, callback) { + if (typeof callback !== 'function') throw new Error('Morphic transactions require a callback'); + if (this.activeTransaction) { + const result = callback(this.activeTransaction); + if (isPromise(result)) throw new Error('Morphic transactions must be synchronous'); + return this.activeTransaction; + } + + const transaction = new MorphicTransaction(this, { + ...options, + id: options?.id || this.nextId() + }); + this.activeTransaction = transaction; + try { + const result = callback(transaction); + if (isPromise(result)) throw new Error('Morphic transactions must be synchronous'); + const changeSet = transaction.commit(); + this.notifyCommitted(changeSet); + return changeSet; + } catch (error) { + if (transaction.state === 'open') transaction.rollback(error); + throw error; + } finally { + this.activeTransaction = null; + } + } + + replay (changeSet, direction) { + if (!(changeSet instanceof MorphicChangeSet)) { + throw new Error('Can only replay a MorphicChangeSet'); + } + if (!Object.values(MorphicReplayDirection).includes(direction)) { + throw new Error(`Unknown replay direction: ${direction}`); + } + const replaySet = direction === MorphicReplayDirection.UNDO + ? changeSet.invert({ + id: this.nextId(), + origin: MorphicReplayDirection.UNDO, + metadata: { replayOf: changeSet.id, replayDirection: direction } + }) + : new MorphicChangeSet({ + id: this.nextId(), + label: changeSet.label, + origin: MorphicReplayDirection.REDO, + undoable: changeSet.undoable, + operations: changeSet.operations, + metadata: { ...changeSet.metadata, replayOf: changeSet.id, replayDirection: direction } + }); + replaySet.apply(this.context); + this.notifyCommitted(replaySet); + return replaySet; + } +} diff --git a/lively.morphic/changes/operations.js b/lively.morphic/changes/operations.js new file mode 100644 index 0000000000..668d56d200 --- /dev/null +++ b/lively.morphic/changes/operations.js @@ -0,0 +1,319 @@ +export const MorphicOperationKind = Object.freeze({ + SET_MORPH_PROPERTY: 'set-morph-property', + INSERT_MORPH: 'insert-morph', + REMOVE_MORPH: 'remove-morph', + MOVE_MORPH: 'move-morph', + REPLACE_TEXT: 'replace-text', + CUSTOM: 'custom-operation' +}); + +export const MorphicAttachmentKind = Object.freeze({ + ATTACHED: 'attached', + DETACHED: 'detached' +}); + +export const MorphicValueSemantics = Object.freeze({ + REFERENCE: 'reference', + SNAPSHOT: 'snapshot' +}); + +const operationKinds = new Set(Object.values(MorphicOperationKind)); +const valueSemanticsKinds = new Set(Object.values(MorphicValueSemantics)); + +function isSnapshotContainer (value) { + if (Array.isArray(value)) return true; + if (!value || typeof value !== 'object') return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function cloneSnapshotContainers (value, freeze = false, seen = new WeakMap()) { + if (!isSnapshotContainer(value)) return value; + if (seen.has(value)) return seen.get(value); + const clone = Array.isArray(value) + ? [] + : Object.create(Object.getPrototypeOf(value)); + seen.set(value, clone); + Reflect.ownKeys(value).forEach(key => { + clone[key] = cloneSnapshotContainers(value[key], freeze, seen); + }); + return freeze ? Object.freeze(clone) : clone; +} + +function snapshotContainersEqual (left, right, seen = new WeakMap()) { + if (Object.is(left, right)) return true; + if (!isSnapshotContainer(left) || !isSnapshotContainer(right) || + Array.isArray(left) !== Array.isArray(right)) return false; + if (seen.get(left) === right) return true; + seen.set(left, right); + const leftKeys = Reflect.ownKeys(left); + const rightKeys = Reflect.ownKeys(right); + return leftKeys.length === rightKeys.length && + leftKeys.every((key, index) => key === rightKeys[index] && + snapshotContainersEqual(left[key], right[key], seen)); +} + +function inferredValueSemantics (before, after) { + return isSnapshotContainer(before) || isSnapshotContainer(after) + ? MorphicValueSemantics.SNAPSHOT + : MorphicValueSemantics.REFERENCE; +} + +function resolveMorph (context, targetId) { + const target = context?.resolveMorph?.(targetId); + if (!target) throw new Error(`Cannot resolve morph ${targetId}`); + return target; +} + +export function attachedMorph ({ ownerId, index, transform = null }) { + if (typeof ownerId !== 'string' || !ownerId) { + throw new Error('Attached morph state requires an ownerId'); + } + if (!Number.isInteger(index) || index < 0) { + throw new Error('Attached morph state requires a non-negative integer index'); + } + return Object.freeze({ + kind: MorphicAttachmentKind.ATTACHED, + ownerId, + index, + transform + }); +} + +export function detachedMorph () { + return Object.freeze({ kind: MorphicAttachmentKind.DETACHED }); +} + +function validateAttachment (attachment, name) { + if (!attachment || attachment.kind === MorphicAttachmentKind.DETACHED) { + if (attachment?.kind !== MorphicAttachmentKind.DETACHED) { + throw new Error(`${name} must be an attached or detached morph state`); + } + return; + } + if (attachment.kind !== MorphicAttachmentKind.ATTACHED || + typeof attachment.ownerId !== 'string' || !attachment.ownerId || + !Number.isInteger(attachment.index) || attachment.index < 0) { + throw new Error(`${name} must be an attached or detached morph state`); + } +} + +export class MorphicOperation { + constructor ({ kind, targetId, before, after, metadata = {}, ...details }) { + if (!operationKinds.has(kind)) throw new Error(`Unknown morphic operation kind: ${kind}`); + if (typeof targetId !== 'string' || !targetId) throw new Error('Morphic operations require a targetId'); + Object.assign(this, { + kind, + targetId, + before, + after, + metadata: Object.freeze({ ...metadata }), + ...details + }); + Object.freeze(this); + } + + validate () {} + apply () { throw new Error(`${this.constructor.name}.apply is not implemented`); } + invert () { throw new Error(`${this.constructor.name}.invert is not implemented`); } +} + +export class SetMorphProperty extends MorphicOperation { + constructor ({ + targetId, + property, + before, + after, + metadata = {}, + valueSemantics = inferredValueSemantics(before, after), + snapshotValue = value => cloneSnapshotContainers(value, true), + materializeValue = value => cloneSnapshotContainers(value), + snapshotValuesEqual = snapshotContainersEqual + }) { + if (typeof property !== 'string' || !property) { + throw new Error('SetMorphProperty requires a property name'); + } + if (!valueSemanticsKinds.has(valueSemantics)) { + throw new Error(`Unknown morphic property value semantics: ${valueSemantics}`); + } + if (valueSemantics === MorphicValueSemantics.SNAPSHOT && + [snapshotValue, materializeValue, snapshotValuesEqual] + .some(callback => typeof callback !== 'function')) { + throw new Error('Snapshot value semantics require snapshot, materialize, and equality hooks'); + } + const operationBefore = valueSemantics === MorphicValueSemantics.SNAPSHOT + ? cloneSnapshotContainers(snapshotValue(before), true) + : before; + const operationAfter = valueSemantics === MorphicValueSemantics.SNAPSHOT + ? cloneSnapshotContainers(snapshotValue(after), true) + : after; + super({ + kind: MorphicOperationKind.SET_MORPH_PROPERTY, + targetId, + property, + before: operationBefore, + after: operationAfter, + metadata, + valueSemantics, + snapshotValue, + materializeValue, + snapshotValuesEqual + }); + } + + validate (context) { + resolveMorph(context, this.targetId); + } + + assertPrecondition (context, target) { + if (context.checkPreconditions === false) return; + const currentValue = context.readMorphProperty + ? context.readMorphProperty(target, this.property) + : target[this.property]; + const valuesEqual = context.valuesEqual || ( + this.valueSemantics === MorphicValueSemantics.SNAPSHOT + ? (current, expected) => this.snapshotValuesEqual( + this.snapshotValue(current), + expected + ) + : Object.is + ); + if (!valuesEqual(currentValue, this.before, this) && + !(this.metadata.acceptAlreadyApplied === true && + valuesEqual(currentValue, this.after, this))) { + throw new Error(`Precondition failed for ${this.targetId}.${this.property}`); + } + } + + apply (context) { + const target = resolveMorph(context, this.targetId); + this.assertPrecondition(context, target); + const value = this.valueSemantics === MorphicValueSemantics.SNAPSHOT + ? cloneSnapshotContainers(this.materializeValue(this.after)) + : this.after; + if (context.setMorphProperty) { + context.setMorphProperty(target, this.property, value, this); + } else { + target[this.property] = value; + } + return this; + } + + invert () { + return new SetMorphProperty({ + targetId: this.targetId, + property: this.property, + before: this.after, + after: this.before, + metadata: this.metadata, + valueSemantics: this.valueSemantics, + snapshotValue: this.snapshotValue, + materializeValue: this.materializeValue, + snapshotValuesEqual: this.snapshotValuesEqual + }); + } +} + +export class MoveMorph extends MorphicOperation { + constructor ({ morphId, from, to, metadata = {} }) { + validateAttachment(from, 'MoveMorph.from'); + validateAttachment(to, 'MoveMorph.to'); + super({ + kind: MorphicOperationKind.MOVE_MORPH, + targetId: morphId, + before: Object.freeze({ ...from }), + after: Object.freeze({ ...to }), + metadata + }); + } + + get morphId () { return this.targetId; } + get from () { return this.before; } + get to () { return this.after; } + + validate (context) { + const morph = resolveMorph(context, this.morphId); + if (this.from.kind === MorphicAttachmentKind.ATTACHED) { + resolveMorph(context, this.from.ownerId); + } + if (this.to.kind === MorphicAttachmentKind.ATTACHED) { + const owner = resolveMorph(context, this.to.ownerId); + if (owner === morph) throw new Error('A morph cannot own itself'); + } + context.validateMoveMorph?.(morph, this.from, this.to, this); + } + + apply (context) { + this.validate(context); + if (typeof context.moveMorph !== 'function') { + throw new Error('MoveMorph requires context.moveMorph'); + } + context.moveMorph( + resolveMorph(context, this.morphId), + this.from, + this.to, + this + ); + return this; + } + + invert () { + return new MoveMorph({ + morphId: this.morphId, + from: this.to, + to: this.from, + metadata: this.metadata + }); + } +} + +export class CustomOperation extends MorphicOperation { + constructor ({ + targetId, + before, + after, + applyHandler, + reverseHandler, + validateHandler = null, + metadata = {} + }) { + if (typeof applyHandler !== 'function' || typeof reverseHandler !== 'function') { + throw new Error('CustomOperation requires explicit apply and reverse handlers'); + } + if (validateHandler && typeof validateHandler !== 'function') { + throw new Error('CustomOperation validateHandler must be a function'); + } + super({ + kind: MorphicOperationKind.CUSTOM, + targetId, + before, + after, + applyHandler, + reverseHandler, + validateHandler, + metadata + }); + } + + validate (context) { + resolveMorph(context, this.targetId); + this.validateHandler?.(context, this); + } + + apply (context) { + this.applyHandler(context, this); + return this; + } + + invert () { + return new CustomOperation({ + targetId: this.targetId, + before: this.after, + after: this.before, + applyHandler: this.reverseHandler, + reverseHandler: this.applyHandler, + validateHandler: this.validateHandler, + metadata: this.metadata + }); + } +} diff --git a/lively.morphic/changes/transaction.js b/lively.morphic/changes/transaction.js new file mode 100644 index 0000000000..fcd237fc0c --- /dev/null +++ b/lively.morphic/changes/transaction.js @@ -0,0 +1,59 @@ +import { MorphicChangeSet, MorphicRollbackError, rollbackOperations } from './change-set.js'; + +export class MorphicTransaction { + constructor (manager, { + id, + label = '', + origin = 'user', + undoable = true, + metadata = {} + }) { + this.manager = manager; + this.id = id; + this.label = label; + this.origin = origin; + this.undoable = !!undoable; + this.metadata = { ...metadata }; + this.operations = []; + this.state = 'open'; + } + + ensureOpen () { + if (this.state !== 'open') throw new Error(`Transaction ${this.id} is ${this.state}`); + } + + perform (operation) { + this.ensureOpen(); + operation.validate(this.manager.context); + operation.apply(this.manager.context); + this.operations.push(operation); + return operation; + } + + commit () { + this.ensureOpen(); + this.state = 'committed'; + return new MorphicChangeSet({ + id: this.id, + label: this.label, + origin: this.origin, + undoable: this.undoable, + operations: this.operations, + metadata: this.metadata + }); + } + + rollback (cause = null) { + this.ensureOpen(); + const rollbackErrors = rollbackOperations(this.operations, this.manager.context); + this.state = 'rolled-back'; + if (rollbackErrors.length) { + throw new MorphicRollbackError( + `Failed to roll back transaction ${this.id} completely`, + cause, + rollbackErrors + ); + } + return this; + } +} diff --git a/lively.morphic/components/policy.js b/lively.morphic/components/policy.js index b8d003c812..664a03caf5 100644 --- a/lively.morphic/components/policy.js +++ b/lively.morphic/components/policy.js @@ -12,6 +12,14 @@ const TRANSFORM_PROPS = ['extent', 'position', 'rotation', 'scale', 'lineHeight' const TEXT_TYPES = ['text', 'label', Text, Label]; const expressionSerializer = new ExpressionSerializer(); +function isTextType (type) { + return withSuperclasses(type).some(candidate => { + if (TEXT_TYPES.includes(candidate)) return true; + const typeName = candidate?.[Symbol.for('__LivelyClassName__')] || candidate?.name; + return typeName === 'Text' || typeName === 'Label'; + }); +} + export function sanitizeSpec (spec) { for (let prop in spec) { if (spec[prop]?.isDefaultValue) spec[prop] = spec[prop].value; @@ -108,6 +116,63 @@ function ensureOrder (originalSubmorphs, adjustedSubmorphs = []) { return arr.sortBy(adjustedSubmorphs, (spec) => originalSubmorphs?.indexOf(originalSubmorphs?.find(elem => elem.name === spec.name))); } +function orderCommandsForForwardAddReferences (commands) { + const additionsByName = new Map(); + const duplicateNames = new Set(); + for (const command of commands) { + const name = command.COMMAND === 'add' && command.props?.name; + if (!name) continue; + if (additionsByName.has(name)) { + additionsByName.delete(name); + duplicateNames.add(name); + } else if (!duplicateNames.has(name)) { + additionsByName.set(name, command); + } + } + + const commandIndexes = new Map(commands.map((command, index) => [command, index])); + const visitState = new Map(); + const hasDependencyCycle = (command) => { + const state = visitState.get(command); + if (state === 'visiting') return true; + if (state === 'visited') return false; + visitState.set(command, 'visiting'); + const dependency = additionsByName.get(command.before); + if (dependency && hasDependencyCycle(dependency)) return true; + visitState.set(command, 'visited'); + return false; + }; + if ([...additionsByName.values()].some(hasDependencyCycle)) return commands; + + const orderedCommands = []; + const waitingForAddition = new Map(); + const deferredCommands = new Set(); + const releaseWaitingCommands = (name) => { + const waitingCommands = waitingForAddition.get(name) || []; + waitingForAddition.delete(name); + for (const command of waitingCommands) { + deferredCommands.delete(command); + orderedCommands.push(command); + releaseWaitingCommands(command.props?.name); + } + }; + + for (const command of commands) { + const dependency = command.COMMAND === 'add' && additionsByName.get(command.before); + if (dependency && commandIndexes.get(dependency) > commandIndexes.get(command)) { + const waitingCommands = waitingForAddition.get(command.before) || []; + waitingCommands.push(command); + waitingForAddition.set(command.before, waitingCommands); + deferredCommands.add(command); + continue; + } + orderedCommands.push(command); + if (command.COMMAND === 'add') releaseWaitingCommands(command.props?.name); + } + + return deferredCommands.size ? commands : orderedCommands; +} + /** * Merges two different specs. */ @@ -137,6 +202,7 @@ function mergeInHierarchy ( } // finally we apply the commands if (!executeCommands) return; + commands = orderCommandsForForwardAddReferences(commands); for (let cmd of commands) { if (cmd.COMMAND === 'remove' && root.submorphs) { const morphToRemove = root.submorphs.find(m => m.name === cmd.target); @@ -149,11 +215,28 @@ function mergeInHierarchy ( if (specOrPolicyToAdd.isPolicy) specOrPolicyToAdd = specOrPolicyToAdd.spec; if (morphToReplace) { + const renamesInheritedMorph = + specOrPolicyToAdd.hasOwnProperty('name') && + specOrPolicyToAdd.name !== cmd.target && + !specOrPolicyToAdd.hasOwnProperty('type'); + if (renamesInheritedMorph) { + mergeInHierarchy( + morphToReplace, + specOrPolicyToAdd, + iterator, + executeCommands, + removeFn, + addFn + ); + continue; + } if (!specOrPolicyToAdd.hasOwnProperty('position')) { specOrPolicyToAdd.position = morphToReplace.spec?.position || morphToReplace.position; } if (!specOrPolicyToAdd.hasOwnProperty('rotation')) { specOrPolicyToAdd.rotation = morphToReplace.spec?.rotation || morphToReplace.rotation; } if (typeof specOrPolicyToAdd.position === 'undefined') delete specOrPolicyToAdd.position; if (typeof specOrPolicyToAdd.rotation === 'undefined') delete specOrPolicyToAdd.rotation; - specOrPolicyToAdd.name = morphToReplace.name; + if (!specOrPolicyToAdd.hasOwnProperty('name')) { + specOrPolicyToAdd.name = morphToReplace.name; + } addFn(root, cmd.props, morphToReplace); removeFn(root, morphToReplace); } @@ -161,7 +244,8 @@ function mergeInHierarchy ( if (cmd.COMMAND === 'add') { if (!root.submorphs) root.submorphs = []; - const beforeMorph = cmd.before && root.submorphs.find(m => m.name === cmd.before); + const beforeMorph = cmd.before && root.submorphs.find(m => + m.name === cmd.before || (m.COMMAND === 'add' && m.props?.name === cmd.before)); addFn(root, cmd.props, beforeMorph); } } @@ -849,7 +933,7 @@ export class StylePolicy { parentSpec.viewModel = obj.deepMerge(parentViewModel, localSpec.viewModel); } - const isTextSpec = arr.intersect(TEXT_TYPES, withSuperclasses(parentSpec.type)).length > 0; + const isTextSpec = isTextType(parentSpec.type); // handle text and attribute merging if (localSpec.textAndAttributes && parentSpec.textAndAttributes && @@ -1244,7 +1328,8 @@ export class StylePolicy { parentSpec, nextLevelSpec); - const isTextSpec = arr.intersect(TEXT_TYPES, withSuperclasses(subSpec.type)).length > 0; + const specType = subSpec.type || nextLevelSpec.type || parentSpec.type; + const isTextSpec = isTextType(specType); if (isTextSpec) { subSpec = handleTextProps({ ...subSpec }); @@ -1268,10 +1353,7 @@ export class StylePolicy { */ getSubSpecFor (submorphName, includeWithoutCalls = false, unwrapAddCalls = true) { if (!submorphName) return this.spec; // assume we ask for root - let embeddedRes; - - let matchingNode = this.lookForMatchingSpec(submorphName, this.spec, includeWithoutCalls); - if (embeddedRes) matchingNode = embeddedRes; + const matchingNode = this.lookForMatchingSpec(submorphName, this.spec, includeWithoutCalls); return matchingNode ? (unwrapAddCalls && matchingNode.props) || matchingNode : null; } @@ -1435,19 +1517,23 @@ export class StylePolicy { */ lookForMatchingSpec (specName, spec = this.spec, includeWithoutCall = false) { let embeddedRes; - return tree.find(spec, node => { + const matchingNode = tree.find(spec, node => { // handle added morphs if (includeWithoutCall && node.COMMAND === 'remove') return node.target === specName; if (node.COMMAND === 'add') return node.props.name === specName; // handle text and attributes (embedded morphs) - if (node.textAndAttributes?.find(textOrAttr => { - if (embeddedRes) return; - if (textOrAttr?.__isSpec__) embeddedRes = this.lookForMatchingSpec(specName, textOrAttr); - if (textOrAttr?.isPolicy && textOrAttr?.name === specName) embeddedRes = textOrAttr; - })) return !!embeddedRes; + for (const textOrAttr of node.textAndAttributes || []) { + if (textOrAttr?.__isSpec__) { + embeddedRes = this.lookForMatchingSpec(specName, textOrAttr); + } else if (textOrAttr?.isPolicy && textOrAttr.name === specName) { + embeddedRes = textOrAttr; + } + if (embeddedRes) return true; + } // handle "normal" case return node.name === specName; - }, node => node.submorphs || node.props?.submorphs) || null; + }, node => node.submorphs || node.props?.submorphs); + return embeddedRes || matchingNode || null; } } @@ -1771,6 +1857,11 @@ export class PolicyApplicator extends StylePolicy { if (removedMorphSpec) { arr.remove(ownerSpec.submorphs, removedMorphSpec); } + for (const subSpec of ownerSpec.submorphs || []) { + if (subSpec.COMMAND === 'add' && subSpec.before === removedMorph.name) { + subSpec.before = null; + } + } if (insertRemoveIfNeeded && !removedMorph.__wasAddedToDerived__) { // insert the without call, but only for non propagation changes if (!ownerSpec.submorphs) ownerSpec.submorphs = []; diff --git a/lively.morphic/layout.js b/lively.morphic/layout.js index f29f0d149d..559ce39418 100644 --- a/lively.morphic/layout.js +++ b/lively.morphic/layout.js @@ -1861,7 +1861,8 @@ export class ConstraintLayout extends Layout { return { submorphSettings, reactToSubmorphAnimations: this.reactToSubmorphAnimations, - lastExtent: this.lastExtent + lastExtent: this.lastExtent, + renderViaCSS: this.renderViaCSS }; } @@ -3174,7 +3175,7 @@ export class GridLayout extends Layout { * @return { GridLayoutSpec } */ getSpec () { - if (!this.container) return this.config; + if (!this.container || !this.grid) return this.config; const grid = []; const rows = []; const columns = []; diff --git a/lively.morphic/morph.js b/lively.morphic/morph.js index e57b77f8e9..bd66eb3ac3 100644 --- a/lively.morphic/morph.js +++ b/lively.morphic/morph.js @@ -1251,6 +1251,10 @@ export class Morph { return this.env.changeManager.addMethodCallChangeDoing(spec, this, doFn); } + addStructuralChangeDoing (spec, doFn) { + return this.env.changeManager.addStructuralChangeDoing(spec, this, doFn); + } + groupChangesWhile (groupChange, whileFn) { return this.env.changeManager.groupChangesWhile(this, groupChange, whileFn); } @@ -1332,7 +1336,12 @@ export class Morph { return [target, animConfig, meta]; }); await Promise.all(animationConfigs.map(([target, animConfig, meta]) => { - return target?.withMetaDo(meta, () => target.animate(animConfig)); + if (!target) return undefined; + let animation; + target.withMetaDo(meta, () => { + animation = target.animate(animConfig); + }); + return animation; })); } @@ -1620,15 +1629,10 @@ export class Morph { this.requestMasterStyling(); - this.addMethodCallChangeDoing({ - target: this, + this.addStructuralChangeDoing({ + morph: submorph, selector: 'addMorphAt', - args: [submorph, index], - undo: { - target: this, - selector: 'removeMorph', - args: [submorph] - } + args: [submorph, index] }, () => { const prevOwner = submorph.owner; const submorphs = this.submorphs; let tfm; @@ -1698,19 +1702,13 @@ export class Morph { const index = this.submorphs.indexOf(morph); if (index === -1) return; - const submorphs = this.getProperty('submorphs') || []; - submorphs.splice(index, 1); - - this.addMethodCallChangeDoing({ - target: this, + this.addStructuralChangeDoing({ + morph, selector: 'removeMorph', - args: [morph], - undo: { - target: this, - selector: 'addMorphAt', - args: [morph, index] - } + args: [morph] }, () => { + const submorphs = this.getProperty('submorphs') || []; + submorphs.splice(index, 1); morph.suspendSteppingAll(); morph._owner = null; }); diff --git a/lively.morphic/tests/change-engine-characterization-test.js b/lively.morphic/tests/change-engine-characterization-test.js new file mode 100644 index 0000000000..578949a988 --- /dev/null +++ b/lively.morphic/tests/change-engine-characterization-test.js @@ -0,0 +1,318 @@ +/* global describe, it, xit, beforeEach, afterEach */ +import { defaultDOMEnv } from '../rendering/dom-helper.js'; +import { morph, MorphicEnv } from '../index.js'; +import { GroupChange } from '../changes.js'; +import { + MorphicValueSemantics, + MoveMorph, + SetMorphProperty +} from '../changes/operations.js'; +import { expect } from 'mocha-es6'; +import { Color, pt } from 'lively.graphics'; + +let env; + +function submorphNames (owner) { + return owner.submorphs.map(submorph => submorph.name); +} + +describe('morphic change engine characterization', function () { + this.timeout(5000); + + beforeEach(async () => { + env = await MorphicEnv.pushDefault(new MorphicEnv(await defaultDOMEnv())); + }); + + afterEach(() => MorphicEnv.popDefault().uninstall()); + + it('records exact scalar property before and after values', () => { + const target = morph({ fill: Color.red }); + const [change] = target.recordChangesWhile(() => target.fill = Color.green); + + expect(change.type).equals('setter'); + expect(change.prop).equals('fill'); + expect(change.prevValue).equals(Color.red); + expect(change.value).equals(Color.green); + expect(change.operation).to.be.instanceOf(SetMorphProperty); + expect(change.operation.before).equals(Color.red); + expect(change.operation.after).equals(Color.green); + }); + + it('combines nested metadata scopes and restores the outer scope', () => { + const target = morph(); + const changes = target.recordChangesWhile(() => { + target.withMetaDo({ origin: 'outer', shared: 'outer' }, () => { + target.fill = Color.red; + target.withMetaDo({ origin: 'inner', nested: true }, () => { + target.opacity = 0.5; + }); + target.rotation = 0.25; + }); + }); + + expect(changes[0].meta).deep.equals({ origin: 'outer', shared: 'outer' }); + expect(changes[1].meta).deep.equals({ origin: 'inner', shared: 'outer', nested: true }); + expect(changes[2].meta).deep.equals({ origin: 'outer', shared: 'outer' }); + expect(env.changeManager.defaultMeta).deep.equals({}); + expect(env.changeManager.metaStack).deep.equals([]); + }); + + it('cleans up a failed change group and propagates its exception', () => { + const target = morph(); + const group = new GroupChange(target); + const expectedError = new Error('failed grouped edit'); + let actualError; + + try { + target.groupChangesWhile(group, () => { + target.fill = Color.red; + throw expectedError; + }); + } catch (error) { + actualError = error; + } + + expect(actualError).equals(expectedError); + expect(env.changeManager.changeGroupStack).deep.equals([]); + const [subsequentChange] = target.recordChangesWhile(() => target.opacity = 0.5); + expect(subsequentChange.prop).equals('opacity'); + expect(subsequentChange.group).equals(null); + }); + + it('notifies the manager, target, and owners in deterministic order', () => { + const owner = morph({ submorphs: [{ name: 'target' }] }); + const target = owner.submorphs[0]; + const notifications = []; + const listener = change => { + if (change.target === target && change.prop === 'fill') notifications.push('manager'); + }; + env.changeManager.addChangeListener(listener); + target.onChange = change => { + if (change.prop === 'fill') notifications.push('target'); + }; + owner.onSubmorphChange = change => { + if (change.prop === 'fill') notifications.push('owner'); + }; + + target.fill = Color.green; + env.changeManager.removeChangeListener(listener); + + expect(notifications).deep.equals(['manager', 'target', 'owner']); + }); + + it('restores a removed morph at its exact sibling index', () => { + const owner = morph({ + submorphs: [{ name: 'a' }, { name: 'b' }, { name: 'c' }] + }); + const removedMorph = owner.submorphs[1]; + + owner.undoStart('remove b'); + removedMorph.remove(); + owner.undoStop(); + expect(submorphNames(owner)).deep.equals(['a', 'c']); + + env.undoManager.undo(); + expect(submorphNames(owner)).deep.equals(['a', 'b', 'c']); + expect(owner.submorphs[1]).equals(removedMorph); + + env.undoManager.redo(); + expect(submorphNames(owner)).deep.equals(['a', 'c']); + expect(removedMorph.owner).equals(null); + }); + + it('does not add undo entries while replaying undo and redo', () => { + const target = morph({ position: pt(0, 0) }); + target.undoStart('move'); + target.position = pt(20, 30); + target.undoStop(); + + expect(env.undoManager.undos).to.have.length(1); + env.undoManager.undo(); + expect(target.position).equals(pt(0, 0)); + expect(env.undoManager.undos).to.have.length(0); + expect(env.undoManager.redos).to.have.length(1); + + env.undoManager.redo(); + expect(target.position).equals(pt(20, 30)); + expect(env.undoManager.undos).to.have.length(1); + expect(env.undoManager.redos).to.have.length(0); + }); + + it('propagates synchronous exceptions from withMetaDo', () => { + const target = morph(); + const expectedError = new Error('failed metadata scope'); + let actualError; + + try { + target.withMetaDo({ origin: 'test' }, () => { throw expectedError; }); + } catch (error) { + actualError = error; + } + + expect(actualError).equals(expectedError); + }); + + it('rejects promise-returning withMetaDo callbacks', async () => { + const target = morph(); + let actualError; + try { + await target.withMetaDo({ origin: 'test' }, async () => target); + } catch (error) { + actualError = error; + } + expect(actualError).to.be.instanceOf(Error); + expect(actualError.message).equals('withMetaDo callbacks must be synchronous'); + }); + + it('starts animations synchronously and awaits them outside their metadata scope', async () => { + const target = morph({ fill: Color.red }); + let animationMeta; + target.animate = () => { + animationMeta = { ...env.changeManager.defaultMeta }; + return Promise.resolve(target); + }; + + await target.withAnimationDo(() => { + target.withMetaDo({ origin: 'component-editor' }, () => target.fill = Color.green); + }, { duration: 0 }); + + expect(animationMeta.origin).equals('component-editor'); + expect(env.changeManager.defaultMeta).deep.equals({}); + expect(env.changeManager.metaStack).deep.equals([]); + }); + + it('gives sibling reordering an exact inverse', () => { + const owner = morph({ + submorphs: [{ name: 'a' }, { name: 'b' }, { name: 'c' }] + }); + const reorderedMorph = owner.submorphs[1]; + + owner.undoStart('reorder b'); + owner.addMorph(reorderedMorph, owner.submorphs[0]); + owner.undoStop(); + expect(submorphNames(owner)).deep.equals(['b', 'a', 'c']); + + env.undoManager.undo(); + expect(submorphNames(owner)).deep.equals(['a', 'b', 'c']); + env.undoManager.redo(); + expect(submorphNames(owner)).deep.equals(['b', 'a', 'c']); + }); + + it('gives reparenting an exact inverse', () => { + const root = morph({ + submorphs: [{ name: 'source', submorphs: [{ name: 'moved' }] }, { name: 'destination' }] + }); + const source = root.submorphs[0]; + const destination = root.submorphs[1]; + const movedMorph = source.submorphs[0]; + + root.undoStart('reparent moved'); + destination.addMorph(movedMorph); + root.undoStop(); + expect(movedMorph.owner).equals(destination); + + env.undoManager.undo(); + expect(movedMorph.owner).equals(source); + expect(source.submorphs).deep.equals([movedMorph]); + env.undoManager.redo(); + expect(movedMorph.owner).equals(destination); + }); + + it('records a reparent as one exact structural operation', () => { + const root = morph({ + submorphs: [{ name: 'source', submorphs: [{ name: 'moved' }] }, { name: 'destination' }] + }); + const source = root.submorphs[0]; + const destination = root.submorphs[1]; + const movedMorph = source.submorphs[0]; + + const changes = root.recordChangesWhile(() => destination.addMorph(movedMorph)); + + expect(changes).to.have.length(1); + expect(changes[0].selector).equals('addMorphAt'); + expect(changes[0].operation).to.be.instanceOf(MoveMorph); + expect(changes[0].operation.from.ownerId).equals(source.id); + expect(changes[0].operation.from.index).equals(0); + expect(changes[0].operation.to.ownerId).equals(destination.id); + expect(changes[0].operation.to.index).equals(0); + }); + + it('preserves operation metadata in replay notifications', () => { + const target = morph({ fill: Color.red }); + const replayMetadata = []; + target.onChange = change => { + if (change.prop === 'fill') replayMetadata.push(change.meta); + }; + + target.undoStart('recolor'); + target.withMetaDo({ origin: 'direct-manipulation' }, () => target.fill = Color.green); + target.undoStop(); + replayMetadata.length = 0; + + env.undoManager.undo(); + expect(replayMetadata[0]).containSubset({ + origin: 'undo', + originalOrigin: 'direct-manipulation', + replayDirection: 'undo' + }); + env.undoManager.redo(); + expect(replayMetadata[1]).containSubset({ + origin: 'redo', + originalOrigin: 'direct-manipulation', + replayDirection: 'redo' + }); + }); + + xit('KNOWN BROKEN: a failed grouped edit rolls back applied changes', () => { + const target = morph({ fill: Color.red, opacity: 1 }); + try { + target.groupChangesWhile(new GroupChange(target), () => { + target.fill = Color.green; + target.opacity = 0.5; + throw new Error('abort'); + }); + } catch (error) { + expect(error.message).equals('abort'); + } + + expect(target.fill).equals(Color.red); + expect(target.opacity).equals(1); + }); + + it('snapshots mutable property values for replay', () => { + const target = morph(); + const nextValue = { nested: { value: 2 } }; + target.setProperty('customState', { nested: { value: 1 } }); + + target.undoStart('set mutable property'); + target.setProperty('customState', nextValue); + target.undoStop(); + nextValue.nested.value = 3; + + env.undoManager.undo(); + env.undoManager.redo(); + expect(target.getProperty('customState')).deep.equals({ nested: { value: 2 } }); + }); + + it('uses property-specific snapshot and materialization policies', () => { + env.changeManager.setPropertyValuePolicy('customDate', { + valueSemantics: MorphicValueSemantics.SNAPSHOT, + snapshotValue: value => value instanceof Date ? value.getTime() : value, + materializeValue: value => value === undefined ? value : new Date(value), + snapshotValuesEqual: (left, right) => left === right + }); + const target = morph(); + const nextValue = new Date(2000); + target.setProperty('customDate', new Date(1000)); + + target.undoStart('set snapshot-policy property'); + target.setProperty('customDate', nextValue); + target.undoStop(); + nextValue.setTime(3000); + + env.undoManager.undo(); + expect(target.getProperty('customDate').getTime()).equals(1000); + env.undoManager.redo(); + expect(target.getProperty('customDate').getTime()).equals(2000); + }); +}); diff --git a/lively.morphic/tests/change-transaction-test.js b/lively.morphic/tests/change-transaction-test.js new file mode 100644 index 0000000000..5120901791 --- /dev/null +++ b/lively.morphic/tests/change-transaction-test.js @@ -0,0 +1,393 @@ +/* global describe, it */ +import { expect } from 'mocha-es6'; +import { + CustomOperation, + MoveMorph, + MorphicChangeSet, + MorphicOperationKind, + MorphicReplayDirection, + MorphicTransactionManager, + MorphicValueSemantics, + SetMorphProperty, + attachedMorph, + detachedMorph +} from '../changes/index.js'; + +function createContext (targets) { + const targetsById = new Map(Object.entries(targets)); + return { + resolveMorph: id => targetsById.get(id), + setMorphProperty: (target, property, value) => { target[property] = value; } + }; +} + +function captureError (callback) { + try { + callback(); + } catch (error) { + return error; + } + return null; +} + +function valueOperation (targetId, before, after) { + const applyValue = (context, operation) => { + context.resolveMorph(operation.targetId).value = operation.after; + }; + return new CustomOperation({ + targetId, + before, + after, + applyHandler: applyValue, + reverseHandler: applyValue + }); +} + +function createTreeContext (root) { + const targetsById = new Map(); + const visit = morph => { + targetsById.set(morph.id, morph); + morph.children.forEach(visit); + }; + visit(root); + + const attachmentOf = morph => morph.owner + ? attachedMorph({ ownerId: morph.owner.id, index: morph.owner.children.indexOf(morph) }) + : detachedMorph(); + + return { + resolveMorph: id => targetsById.get(id), + validateMoveMorph: (morph, from, to) => { + expect(attachmentOf(morph)).deep.equals(from); + if (to.kind === 'attached') { + let owner = targetsById.get(to.ownerId); + while (owner) { + if (owner === morph) throw new Error('MoveMorph cannot create an ownership cycle'); + owner = owner.owner; + } + } + }, + moveMorph: (morph, from, to) => { + if (from.kind === 'attached') { + const source = targetsById.get(from.ownerId); + source.children.splice(source.children.indexOf(morph), 1); + morph.owner = null; + } + if (to.kind === 'attached') { + const destination = targetsById.get(to.ownerId); + destination.children.splice(to.index, 0, morph); + morph.owner = destination; + } + } + }; +} + +function treeNode (id, children = []) { + const node = { id, owner: null, children }; + children.forEach(child => { child.owner = node; }); + return node; +} + +describe('morphic transaction kernel', () => { + it('defines closed immutable property operations with exact inverses', () => { + const operation = new SetMorphProperty({ + targetId: 'target', + property: 'fill', + before: 'red', + after: 'green', + metadata: { origin: 'test' } + }); + const inverse = operation.invert(); + + expect(operation.kind).equals(MorphicOperationKind.SET_MORPH_PROPERTY); + expect(Object.isFrozen(operation)).to.be.true; + expect(Object.isFrozen(operation.metadata)).to.be.true; + expect(inverse.before).equals('green'); + expect(inverse.after).equals('red'); + expect(inverse.metadata).deep.equals(operation.metadata); + }); + + it('snapshots mutable containers and materializes fresh replay values', () => { + const before = { nested: { value: 1 } }; + const after = { nested: { value: 2 } }; + const operation = new SetMorphProperty({ + targetId: 'target', property: 'state', before, after + }); + const target = { state: before }; + const context = createContext({ target }); + + after.nested.value = 3; + expect(operation.valueSemantics).equals(MorphicValueSemantics.SNAPSHOT); + expect(Object.isFrozen(operation.after)).to.be.true; + expect(Object.isFrozen(operation.after.nested)).to.be.true; + operation.apply(context); + expect(target.state).deep.equals({ nested: { value: 2 } }); + expect(target.state).not.equals(operation.after); + target.state.nested.value = 4; + expect(operation.after).deep.equals({ nested: { value: 2 } }); + }); + + it('supports explicit reference semantics for identity-bearing values', () => { + const before = []; + const after = []; + const operation = new SetMorphProperty({ + targetId: 'target', + property: 'items', + before, + after, + valueSemantics: MorphicValueSemantics.REFERENCE + }); + + expect(operation.before).equals(before); + expect(operation.after).equals(after); + expect(operation.invert().before).equals(after); + }); + + it('accepts an explicitly idempotent property operation at its postcondition', () => { + const target = { fill: 'green' }; + const operation = new SetMorphProperty({ + targetId: 'target', + property: 'fill', + before: 'red', + after: 'green', + metadata: { acceptAlreadyApplied: true } + }); + const context = createContext({ target }); + + expect(() => operation.apply(context)).not.to.throw(); + expect(target.fill).equals('green'); + target.fill = 'blue'; + expect(() => operation.apply(context)).to.throw(/Precondition failed/); + }); + + it('supports property-domain snapshot and materialization hooks', () => { + const before = new Date(1000); + const after = new Date(2000); + const operation = new SetMorphProperty({ + targetId: 'target', + property: 'date', + before, + after, + valueSemantics: MorphicValueSemantics.SNAPSHOT, + snapshotValue: value => value instanceof Date ? value.getTime() : value, + materializeValue: value => new Date(value), + snapshotValuesEqual: (left, right) => left === right + }); + const target = { date: before }; + const context = createContext({ target }); + + operation.apply(context); + expect(target.date).to.be.instanceOf(Date); + expect(target.date.getTime()).equals(2000); + operation.invert().apply(context); + expect(target.date.getTime()).equals(1000); + }); + + it('represents insertion, removal, reordering, and reparenting as exact moves', () => { + const a = treeNode('a'); + const b = treeNode('b'); + const c = treeNode('c'); + const destination = treeNode('destination'); + const root = treeNode('root', [treeNode('source', [a, b, c]), destination]); + const source = root.children[0]; + const context = createTreeContext(root); + + const reorder = new MoveMorph({ + morphId: 'b', + from: attachedMorph({ ownerId: 'source', index: 1 }), + to: attachedMorph({ ownerId: 'source', index: 0 }) + }); + reorder.apply(context); + expect(source.children.map(({ id }) => id)).deep.equals(['b', 'a', 'c']); + reorder.invert().apply(context); + expect(source.children.map(({ id }) => id)).deep.equals(['a', 'b', 'c']); + + const reparent = new MoveMorph({ + morphId: 'b', + from: attachedMorph({ ownerId: 'source', index: 1 }), + to: attachedMorph({ ownerId: 'destination', index: 0 }) + }); + reparent.apply(context); + expect(source.children.map(({ id }) => id)).deep.equals(['a', 'c']); + expect(destination.children).deep.equals([b]); + reparent.invert().apply(context); + expect(source.children.map(({ id }) => id)).deep.equals(['a', 'b', 'c']); + + const remove = new MoveMorph({ + morphId: 'b', + from: attachedMorph({ ownerId: 'source', index: 1 }), + to: detachedMorph() + }); + remove.apply(context); + expect(b.owner).equals(null); + remove.invert().apply(context); + expect(source.children.map(({ id }) => id)).deep.equals(['a', 'b', 'c']); + }); + + it('rejects structural moves with stale source locations or ownership cycles', () => { + const child = treeNode('child'); + const parent = treeNode('parent', [child]); + const root = treeNode('root', [parent]); + const context = createTreeContext(root); + const staleMove = new MoveMorph({ + morphId: 'child', + from: attachedMorph({ ownerId: 'parent', index: 1 }), + to: detachedMorph() + }); + const cyclicMove = new MoveMorph({ + morphId: 'parent', + from: attachedMorph({ ownerId: 'root', index: 0 }), + to: attachedMorph({ ownerId: 'child', index: 0 }) + }); + + expect(captureError(() => staleMove.apply(context))).to.be.instanceOf(Error); + expect(captureError(() => cyclicMove.apply(context)).message) + .equals('MoveMorph cannot create an ownership cycle'); + expect(root.children).deep.equals([parent]); + expect(parent.children).deep.equals([child]); + }); + + it('validates every target before applying a change set', () => { + const firstTarget = { value: 0 }; + const context = createContext({ first: firstTarget }); + const changeSet = new MorphicChangeSet({ + id: 'invalid-target', + operations: [ + new SetMorphProperty({ targetId: 'first', property: 'value', before: 0, after: 1 }), + new SetMorphProperty({ targetId: 'missing', property: 'value', before: 0, after: 1 }) + ] + }); + + const error = captureError(() => changeSet.apply(context)); + expect(error.message).equals('Cannot resolve morph missing'); + expect(firstTarget.value).equals(0); + }); + + it('rolls back already applied operations when a later operation fails', () => { + const firstTarget = { value: 0 }; + const secondTarget = { value: 0 }; + const context = createContext({ first: firstTarget, second: secondTarget }); + const failingOperation = new CustomOperation({ + targetId: 'second', + before: 0, + after: 1, + applyHandler: () => { throw new Error('apply failed'); }, + reverseHandler: () => {} + }); + const changeSet = new MorphicChangeSet({ + id: 'atomic-application', + operations: [valueOperation('first', 0, 1), failingOperation] + }); + + const error = captureError(() => changeSet.apply(context)); + expect(error.message).equals('apply failed'); + expect(firstTarget.value).equals(0); + expect(secondTarget.value).equals(0); + }); + + it('joins nested operations and notifies only after the outer commit', () => { + const firstTarget = { value: 0 }; + const secondTarget = { value: 0 }; + const manager = new MorphicTransactionManager( + createContext({ first: firstTarget, second: secondTarget }) + ); + const notifications = []; + manager.addCommitListener(changeSet => notifications.push(changeSet)); + + const changeSet = manager.transaction({ label: 'outer', origin: 'test' }, transaction => { + transaction.perform(new SetMorphProperty({ + targetId: 'first', property: 'value', before: 0, after: 1 + })); + const nestedTransaction = manager.transaction({ label: 'nested' }, nested => { + nested.perform(new SetMorphProperty({ + targetId: 'second', property: 'value', before: 0, after: 2 + })); + }); + expect(nestedTransaction).equals(transaction); + expect(notifications).to.have.length(0); + }); + + expect(firstTarget.value).equals(1); + expect(secondTarget.value).equals(2); + expect(changeSet.operations).to.have.length(2); + expect(Object.isFrozen(changeSet.operations)).to.be.true; + expect(notifications).deep.equals([changeSet]); + }); + + it('rolls back an entire transaction and suppresses commit notification on failure', () => { + const firstTarget = { value: 0 }; + const secondTarget = { value: 0 }; + const manager = new MorphicTransactionManager( + createContext({ first: firstTarget, second: secondTarget }) + ); + const notifications = []; + manager.addCommitListener(changeSet => notifications.push(changeSet)); + + const error = captureError(() => manager.transaction({ label: 'failing' }, transaction => { + transaction.perform(new SetMorphProperty({ + targetId: 'first', property: 'value', before: 0, after: 1 + })); + transaction.perform(new CustomOperation({ + targetId: 'second', + before: 0, + after: 1, + applyHandler: () => { throw new Error('transaction failed'); }, + reverseHandler: () => {} + })); + })); + + expect(error.message).equals('transaction failed'); + expect(firstTarget.value).equals(0); + expect(secondTarget.value).equals(0); + expect(manager.activeTransaction).equals(null); + expect(notifications).deep.equals([]); + }); + + it('rejects asynchronous callbacks and rolls back synchronous mutations', () => { + const target = { value: 0 }; + const manager = new MorphicTransactionManager(createContext({ target })); + + const error = captureError(() => manager.transaction({ label: 'async' }, transaction => { + transaction.perform(new SetMorphProperty({ + targetId: 'target', property: 'value', before: 0, after: 1 + })); + return Promise.resolve(); + })); + + expect(error.message).equals('Morphic transactions must be synchronous'); + expect(target.value).equals(0); + }); + + it('replays undo and redo with explicit origins and directions', () => { + const target = { fill: 'red' }; + const manager = new MorphicTransactionManager(createContext({ target })); + const notifications = []; + manager.addCommitListener(changeSet => notifications.push(changeSet)); + + const committed = manager.transaction({ + label: 'recolor', + origin: 'direct-manipulation' + }, transaction => { + transaction.perform(new SetMorphProperty({ + targetId: 'target', property: 'fill', before: 'red', after: 'green' + })); + }); + const undo = manager.replay(committed, MorphicReplayDirection.UNDO); + const redo = manager.replay(committed, MorphicReplayDirection.REDO); + + expect(target.fill).equals('green'); + expect(notifications).deep.equals([committed, undo, redo]); + expect(undo.origin).equals('undo'); + expect(undo.metadata.replayDirection).equals('undo'); + expect(redo.origin).equals('redo'); + expect(redo.metadata.replayDirection).equals('redo'); + }); + + it('requires custom operations to provide an explicit inverse handler', () => { + const error = captureError(() => new CustomOperation({ + targetId: 'target', + applyHandler: () => {} + })); + expect(error.message).equals('CustomOperation requires explicit apply and reverse handlers'); + }); +}); diff --git a/lively.morphic/tests/changes-test.js b/lively.morphic/tests/changes-test.js index 8cb67fd17a..2e4f1987b6 100644 --- a/lively.morphic/tests/changes-test.js +++ b/lively.morphic/tests/changes-test.js @@ -1,5 +1,5 @@ /* global it, describe, beforeEach, afterEach */ -import { morph, MorphicEnv } from '../index.js'; +import { morph, MorphicEnv, Text } from '../index.js'; import { GroupChange } from '../changes.js'; import { expect } from 'mocha-es6'; import { pt, Color } from 'lively.graphics'; @@ -165,6 +165,112 @@ describe('changes', function () { }); }); + describe('committed change sets', () => { + it('publishes an applied property operation with origin and target context', () => { + const target = morph({ fill: Color.red }); + const owner = morph({ submorphs: [target] }); + const commits = []; + const listener = (changeSet, context) => commits.push({ changeSet, context }); + env.changeManager.addCommittedChangeListener(listener); + + target.withMetaDo({ origin: 'direct-manipulation' }, () => target.fill = Color.green); + env.changeManager.removeCommittedChangeListener(listener); + + expect(commits).to.have.length(1); + expect(commits[0].changeSet.origin).equals('direct-manipulation'); + expect(commits[0].changeSet.operations).to.have.length(1); + expect(commits[0].changeSet.operations[0].property).equals('fill'); + expect(commits[0].context.resolveMorph(target.id)).equals(target); + expect(commits[0].context.resolveMorph(owner.id)).equals(owner); + }); + + it('publishes one committed set for a grouped operation-bearing change', () => { + const target = morph({ fill: Color.red, opacity: 1 }); + const commits = []; + env.changeManager.addCommittedChangeListener((changeSet, context) => + commits.push({ changeSet, context })); + + const group = new GroupChange(target); + target.groupChangesWhile(group, () => { + target.fill = Color.green; + target.opacity = 0.5; + }); + + expect(commits).to.have.length(1); + expect(commits[0].changeSet.operations).to.have.length(2); + expect(commits[0].context.legacyChanges).to.have.length(2); + expect(commits[0].context.committedChange).equals(group); + }); + + it('does not manufacture semantic commits for unsupported method records', () => { + const target = morph(); + const commits = []; + env.changeManager.addCommittedChangeListener(changeSet => commits.push(changeSet)); + + target.addMethodCallChangeDoing({ + target, + selector: 'unsupportedMutation', + args: [], + undo: () => {} + }, () => {}); + + expect(commits).deep.equals([]); + }); + + it('normalizes an interactive text range replacement into one property operation', () => { + const target = new Text({ readOnly: false, textString: 'initial text' }); + const commits = []; + env.changeManager.addCommittedChangeListener((changeSet, context) => + commits.push({ changeSet, context })); + + target.withMetaDo({ origin: 'interactive-input' }, () => { + target.replace({ + start: { row: 0, column: 8 }, + end: { row: 0, column: 12 } + }, 'value', false, true, true); + }); + + expect(target.textString).equals('initial value'); + expect(commits).to.have.length(1); + const [operation] = commits[0].changeSet.operations; + expect(operation.property).equals('textAndAttributes'); + expect(operation.before).deep.equals(['initial text', null]); + expect(operation.after).deep.equals(['initial value', null]); + expect(operation.metadata.textReplacement).equals(true); + expect(commits[0].context.resolveMorph(target.id)).equals(target); + }); + + it('does not double-commit a full textAndAttributes assignment', () => { + const target = new Text({ readOnly: false, textString: 'initial text' }); + const commits = []; + env.changeManager.addCommittedChangeListener(changeSet => commits.push(changeSet)); + + target.withMetaDo({ origin: 'programmatic-rich-text' }, () => { + target.textAndAttributes = ['replacement', { fontWeight: 'bold' }]; + }); + + expect(commits).to.have.length(1); + expect(commits[0].operations).to.have.length(1); + expect(commits[0].operations[0].property).equals('textAndAttributes'); + expect(commits[0].operations[0].metadata.textReplacement).not.equals(true); + }); + + it('publishes explicit origins while replaying legacy undo and redo', () => { + const target = morph({ fill: Color.red }); + const origins = []; + env.changeManager.addCommittedChangeListener(changeSet => origins.push(changeSet.origin)); + target.undoStart('recolor'); + target.withMetaDo({ origin: 'direct-manipulation' }, () => target.fill = Color.green); + target.undoStop(); + origins.length = 0; + + env.undoManager.undo(); + env.undoManager.redo(); + + expect(origins).deep.equals(['undo', 'redo']); + }); + }); + describe('animations', () => { it('enques a new animation when setting prop animated', () => { let m = morph({ extent: pt(10, 20), fill: Color.red }); diff --git a/lively.morphic/tests/components-test.cp.js b/lively.morphic/tests/components-test.cp.js index ad8712adba..d98fd53179 100644 --- a/lively.morphic/tests/components-test.cp.js +++ b/lively.morphic/tests/components-test.cp.js @@ -5,7 +5,7 @@ import { Color, pt } from 'lively.graphics'; import { tree, grid } from 'lively.lang'; import { serialize } from 'lively.serializer2'; import { ComponentDescriptor, TilingLayout, Text, Morph, morph } from 'lively.morphic'; -import { component, ViewModel, without, part, add } from '../components/core.js'; +import { component, ViewModel, without, part, add, replace } from '../components/core.js'; import { StylePolicy, sanitizeSpec, BreakpointStore, PolicyApplicator } from '../components/policy.js'; import { getDefaultValuesFor } from '../helpers.js'; @@ -935,6 +935,32 @@ describe('components', () => { expect(m.textString).to.eql('hello world'); }); + it('normalizes text overrides whose nested spec inherits its type', () => { + const Base = ComponentDescriptor.for(() => component({ + name: 'base with nested text', + submorphs: [{ + type: Text, + name: 'message', + textString: 'inherited text' + }] + }), { + exportedName: 'BaseWithNestedText', + moduleId + }); + const Derived = ComponentDescriptor.for(() => component(Base, { + name: 'derived with nested text override', + submorphs: [{ + name: 'message', + textAndAttributes: ['reconciled text', null] + }] + }), { + exportedName: 'DerivedWithNestedText', + moduleId + }); + + expect(part(Derived).get('message').textString).to.eql('reconciled text'); + }); + it('properly assigns custom generated names in case of a conflict', () => { const C = ComponentDescriptor.for(() => component(c6, { submorphs: [ @@ -955,6 +981,64 @@ describe('components', () => { expect(m.submorphs[0].submorphs[0].name).to.eql('c2'); expect(m.submorphs[0].submorphs[1].name).not.to.eql('c2'); }); + + it('resolves before references to morphs introduced by later add commands', () => { + const Base = ComponentDescriptor.for(() => component({ + name: 'base with existing morph', + submorphs: [{ name: 'existing' }] + }), { + exportedName: 'Base', + moduleId + }); + const Derived = ComponentDescriptor.for(() => component(Base, { + name: 'derived with chained additions', + submorphs: [ + add({ name: 'first' }, 'second'), + add({ name: 'second' }, 'existing') + ] + }), { + exportedName: 'Derived', + moduleId + }); + + expect(part(Derived).submorphs.map(m => m.name)).to.eql([ + 'first', + 'second', + 'existing' + ]); + }); + + it('allows replacement commands to rename their target', () => { + const Base = ComponentDescriptor.for(() => component({ + name: 'base with replaceable morph', + submorphs: [ + { type: Text, name: 'before', textString: 'text' }, + { name: 'existing' } + ] + }), { + exportedName: 'ReplaceBase', + moduleId + }); + const Derived = ComponentDescriptor.for(() => component(Base, { + name: 'derived with renamed replacement', + submorphs: [ + replace('before', { name: 'after' }), + add({ name: 'inserted' }, 'after') + ] + }), { + exportedName: 'ReplaceDerived', + moduleId + }); + const instance = part(Derived); + + expect(instance.submorphs.map(m => m.name)).to.eql([ + 'inserted', + 'after', + 'existing' + ]); + expect(instance.get('after')).to.be.instanceof(Text); + expect(instance.get('after').textString).equals('text'); + }); }); describe('breakpoints', () => { diff --git a/lively.morphic/tests/layout-test.js b/lively.morphic/tests/layout-test.js index c4576042fa..b4399511cd 100644 --- a/lively.morphic/tests/layout-test.js +++ b/lively.morphic/tests/layout-test.js @@ -549,6 +549,23 @@ describe('layout', () => { expect(m1.position).equals(pt(200, 0)); }); + it('serializes a grid with a container before it is initialized', () => { + const container = morph(); + const layout = new GridLayout({ + autoAssign: true, + columnCount: 2, + rowCount: 1, + renderViaCSS: false + }); + layout.container = container; + + const serialized = layout.__serialize__().__expr__; + expect(serialized).includes('"autoAssign": true'); + expect(serialized).includes('"columnCount": 2'); + expect(serialized).includes('"rowCount": 1'); + expect(serialized).not.includes('"grid"'); + }); + it('allows morphs to take up multiple cells', () => { const [m1, m2, m3] = m.submorphs; // eslint-disable-line no-unused-vars m.layout = new GridLayout({ @@ -909,6 +926,11 @@ describe('layout', () => { afterEach(() => container.remove()); + it('preserves the rendering mode in its attached spec', () => { + expect(container.layout.getSpec().renderViaCSS).equals(false); + expect(container.layout.copy().renderViaCSS).equals(false); + }); + it('does not resize by default', () => { checkJSAndCSS(container, () => { container.extent = pt(120, 120); diff --git a/lively.morphic/tests/undo-test.js b/lively.morphic/tests/undo-test.js index 3cee5b284c..a186fee858 100644 --- a/lively.morphic/tests/undo-test.js +++ b/lively.morphic/tests/undo-test.js @@ -4,9 +4,31 @@ import { morph, MorphicEnv } from '../index.js'; import { expect } from 'mocha-es6'; import { pt, Color } from 'lively.graphics'; import { arr } from 'lively.lang'; +import { + CompositeEditTransaction, + EditTransaction, + EditTransactionKind, + MorphicChangeSetTransaction +} from '../undo.js'; +import { + MorphicReplayDirection, + MorphicTransactionManager, + SetMorphProperty +} from '../changes/index.js'; let env; +class TestEditTransaction extends EditTransaction { + constructor ({ label, kind = EditTransactionKind.COMPONENT_COMMAND, apply, reverseApply }) { + super({ kind, label }); + this.applyHandler = apply; + this.reverseApplyHandler = reverseApply; + } + + apply () { this.applyHandler(); return this; } + reverseApply () { this.reverseApplyHandler(); return this; } +} + describe('undo', () => { beforeEach(async () => env = await MorphicEnv.pushDefault(new MorphicEnv(await defaultDOMEnv()))); afterEach(() => MorphicEnv.popDefault().uninstall()); @@ -67,6 +89,30 @@ describe('undo', () => { .equals([m1, m2]); }); + it('does not record structural changes of transient morphs', () => { + const root = morph({ + submorphs: [ + { name: 'target', extent: pt(10, 10) }, + { name: 'selection', epiMorph: true } + ] + }); + const target = root.getSubmorphNamed('target'); + const selection = root.getSubmorphNamed('selection'); + + root.undoStart('resize with expiring selection'); + target.extent = pt(20, 20); + selection.remove(); + root.undoStop(); + + // A transient overlay can independently be reused or reattached before + // the user's next undo. Its lifecycle must not stale the resize record. + root.dontRecordChangesWhile(() => root.addMorph(selection)); + + expect(() => env.undoManager.undo()).not.to.throw(); + expect(target.extent).equals(pt(10, 10)); + expect(selection.owner).equals(root); + }); + it('can have multiple targets', () => { let m1 = morph(); let m2 = m1.addMorph({}); let m3 = morph(); m1.undoStart('test').addTarget(m3); @@ -78,4 +124,136 @@ describe('undo', () => { expect(arr.uniq(env.undoManager.undos.flatMap(({ changes }) => arr.pluck(changes, 'target')))) .equals([m1, m2, m3]); }); + + it('stores and replays generic edit transactions', () => { + const state = { value: 2 }; + const transaction = new TestEditTransaction({ + label: 'component property', + apply: () => { state.value = 2; }, + reverseApply: () => { state.value = 1; } + }); + + env.undoManager.addTransaction(transaction); + env.undoManager.undo(); + expect(state.value).equals(1); + env.undoManager.redo(); + expect(state.value).equals(2); + }); + + it('replaces recorded changes with a generic transaction in the active undo', () => { + const target = morph({ fill: Color.red }); + target.undoStart('projectional property'); + target.fill = Color.green; + const [legacyChange] = env.undoManager.undoInProgress.recorder.changes; + const transaction = new TestEditTransaction({ + label: 'component property', + apply: () => { target.fill = Color.green; }, + reverseApply: () => { target.fill = Color.red; } + }); + + expect(env.undoManager.discardRecordedChanges([legacyChange])).equals(1); + env.undoManager.addTransaction(transaction, { joinActive: true }); + const joined = target.undoStop(); + + expect(joined).to.be.instanceOf(CompositeEditTransaction); + expect(joined.transactions[1]).equals(transaction); + env.undoManager.undo(); + expect(target.fill).equals(Color.red); + env.undoManager.redo(); + expect(target.fill).equals(Color.green); + }); + + it('groups mixed-domain transactions and replays them in domain order', () => { + const replayOrder = []; + const componentTransaction = new TestEditTransaction({ + label: 'component', + apply: () => replayOrder.push('apply component'), + reverseApply: () => replayOrder.push('reverse component') + }); + const textTransaction = new TestEditTransaction({ + label: 'text', + kind: EditTransactionKind.TEXT, + apply: () => replayOrder.push('apply text'), + reverseApply: () => replayOrder.push('reverse text') + }); + + env.undoManager.addTransaction(componentTransaction); + env.undoManager.addTransaction(textTransaction); + const grouped = env.undoManager.group(); + + expect(grouped).to.be.instanceOf(CompositeEditTransaction); + expect(env.undoManager.undos).deep.equals([grouped]); + env.undoManager.undo(); + expect(replayOrder).deep.equals(['reverse text', 'reverse component']); + env.undoManager.redo(); + expect(replayOrder).deep.equals([ + 'reverse text', + 'reverse component', + 'apply component', + 'apply text' + ]); + }); + + it('rolls back already replayed domains when a composite apply fails', () => { + const state = { value: 0 }; + const first = new TestEditTransaction({ + label: 'first', + apply: () => { state.value = 1; }, + reverseApply: () => { state.value = 0; } + }); + const failing = new TestEditTransaction({ + label: 'failing', + apply: () => { throw new Error('cross-domain failure'); }, + reverseApply: () => {} + }); + const composite = new CompositeEditTransaction([first, failing]); + let actualError; + + try { composite.apply(); } catch (error) { actualError = error; } + + expect(actualError.message).equals('cross-domain failure'); + expect(state.value).equals(0); + }); + + it('keeps journal stacks unchanged when replay fails', () => { + const transaction = new TestEditTransaction({ + label: 'failing reverse', + apply: () => {}, + reverseApply: () => { throw new Error('reverse failed'); } + }); + env.undoManager.addTransaction(transaction); + let actualError; + + try { env.undoManager.undo(); } catch (error) { actualError = error; } + + expect(actualError.message).equals('reverse failed'); + expect(env.undoManager.undos).deep.equals([transaction]); + expect(env.undoManager.redos).deep.equals([]); + }); + + it('stores morphic change sets with explicit undo and redo replay origins', () => { + const target = { value: 0 }; + const replayed = []; + const manager = new MorphicTransactionManager({ + resolveMorph: id => id === 'target' ? target : null, + setMorphProperty: (morph, property, value) => { morph[property] = value; } + }); + manager.addCommitListener(changeSet => replayed.push(changeSet)); + const changeSet = manager.transaction({ label: 'set value' }, transaction => { + transaction.perform(new SetMorphProperty({ + targetId: 'target', property: 'value', before: 0, after: 1 + })); + }); + env.undoManager.addTransaction(new MorphicChangeSetTransaction(changeSet, manager)); + replayed.length = 0; + + env.undoManager.undo(); + env.undoManager.redo(); + + expect(target.value).equals(1); + expect(replayed.map(({ origin }) => origin)).deep.equals([ + MorphicReplayDirection.UNDO, + MorphicReplayDirection.REDO + ]); + }); }); diff --git a/lively.morphic/text/morph.js b/lively.morphic/text/morph.js index cd507cec45..cbfd0ea4c1 100644 --- a/lively.morphic/text/morph.js +++ b/lively.morphic/text/morph.js @@ -479,11 +479,12 @@ export class Text extends Morph { if (obj.isArray(textAndAttributes) && textAndAttributes.find(m => m?.doit)) { this.needsDocument = true; } if (obj.isArray(textAndAttributes) && textAndAttributes.find(m => m?.isMorph)) { this.needsDocument = true; } if (this.document) { - this.replace( - { start: { row: 0, column: 0 }, end: this.documentEndPosition }, - textAndAttributes, - false - ); + this.withMetaDo({ partOfTextAndAttributesAssignment: true }, () => + this.replace( + { start: { row: 0, column: 0 }, end: this.documentEndPosition }, + textAndAttributes, + false + )); } else { if (textAndAttributes.length === 0) textAndAttributes = ['', null]; if (typeof textAndAttributes === 'string') textAndAttributes = [textAndAttributes, null]; diff --git a/lively.morphic/undo.js b/lively.morphic/undo.js index 067f3004f6..e6b006d4e0 100644 --- a/lively.morphic/undo.js +++ b/lively.morphic/undo.js @@ -1,13 +1,147 @@ -import { obj, arr, events, fun } from 'lively.lang'; +import { obj, arr, fun } from 'lively.lang'; +import { MorphicChangeSet } from './changes/change-set.js'; +import { MorphicReplayDirection } from './changes/manager.js'; -class Undo { +export const EditTransactionKind = Object.freeze({ + RECORDED_MORPH_CHANGES: 'recorded-morph-changes', + MORPHIC_CHANGE_SET: 'morphic-change-set', + COMPOSITE: 'composite', + COMPONENT_COMMAND: 'component-command', + TEXT: 'text' +}); + +const transactionKinds = new Set(Object.values(EditTransactionKind)); + +export class EditTransaction { + constructor ({ kind, label, no = 0, timestamp = null, metadata = {} }) { + if (!transactionKinds.has(kind)) throw new Error(`Unknown edit transaction kind: ${kind}`); + if (typeof label !== 'string') throw new Error('Edit transactions require a label'); + this.kind = kind; + this.label = label; + this.name = label; + this.no = no; + this.timestamp = timestamp; + this.metadata = Object.freeze({ ...metadata }); + } + + apply () { throw new Error(`${this.constructor.name}.apply is not implemented`); } + reverseApply () { throw new Error(`${this.constructor.name}.reverseApply is not implemented`); } + canMergeWith () { return false; } + merge () { throw new Error(`${this.constructor.name} cannot merge transactions`); } +} + +export class EditTransactionRollbackError extends Error { + constructor (message, cause, rollbackErrors) { + super(message); + this.name = 'EditTransactionRollbackError'; + this.cause = cause; + this.rollbackErrors = rollbackErrors; + } +} + +function compensate (transactions, selector) { + const rollbackErrors = []; + transactions.slice().reverse().forEach(transaction => { + try { transaction[selector](); } catch (error) { rollbackErrors.push(error); } + }); + return rollbackErrors; +} + +export class CompositeEditTransaction extends EditTransaction { + constructor (transactions, { label, no, timestamp, metadata = {} } = {}) { + if (!transactions.length) throw new Error('Composite edit transactions cannot be empty'); + if (transactions.some(transaction => !(transaction instanceof EditTransaction))) { + throw new Error('Composite entries must be EditTransaction instances'); + } + super({ + kind: EditTransactionKind.COMPOSITE, + label: label || transactions.map(transaction => transaction.label).join('-'), + no: no ?? transactions[0].no, + timestamp: timestamp ?? transactions[0].timestamp, + metadata + }); + this.transactions = Object.freeze(transactions.slice()); + } + + apply () { + const applied = []; + try { + this.transactions.forEach(transaction => { + transaction.apply(); + applied.push(transaction); + }); + } catch (error) { + const rollbackErrors = compensate(applied, 'reverseApply'); + if (rollbackErrors.length) { + throw new EditTransactionRollbackError( + `Failed to apply ${this.label} and to roll it back completely`, + error, + rollbackErrors + ); + } + throw error; + } + return this; + } + + reverseApply () { + const reversed = []; + try { + this.transactions.slice().reverse().forEach(transaction => { + transaction.reverseApply(); + reversed.push(transaction); + }); + } catch (error) { + const rollbackErrors = compensate(reversed, 'apply'); + if (rollbackErrors.length) { + throw new EditTransactionRollbackError( + `Failed to reverse ${this.label} and to restore it completely`, + error, + rollbackErrors + ); + } + throw error; + } + return this; + } +} + +export class MorphicChangeSetTransaction extends EditTransaction { + constructor (changeSet, manager, { label = changeSet?.label || '', no = 0 } = {}) { + if (!(changeSet instanceof MorphicChangeSet)) { + throw new Error('MorphicChangeSetTransaction requires a MorphicChangeSet'); + } + if (!manager || typeof manager.replay !== 'function') { + throw new Error('MorphicChangeSetTransaction requires a transaction manager'); + } + super({ + kind: EditTransactionKind.MORPHIC_CHANGE_SET, + label, + no, + metadata: { changeSetId: changeSet.id } + }); + this.changeSet = changeSet; + this.manager = manager; + } + + apply () { + this.manager.replay(this.changeSet, MorphicReplayDirection.REDO); + return this; + } + + reverseApply () { + this.manager.replay(this.changeSet, MorphicReplayDirection.UNDO); + return this; + } +} + +export class RecordedChangeTransaction extends EditTransaction { constructor (name, targets = [], no = 0) { - this.name = name; + super({ kind: EditTransactionKind.RECORDED_MORPH_CHANGES, label: name, no }); this.targets = targets; this.recorder = null; this.changes = null; - this.timestamp = null; - this.no = no; + this.joinedTransactions = []; } recorded () { return !!this.changes; } @@ -21,8 +155,9 @@ class Undo { const morph = this.targets[0]; this.recorder = morph.recordChangesStart(change => { - const { target } = change; - if (target.isUsedAsEpiMorph()) return false; + const { target, morph: structurallyChangedMorph, owners = [] } = change; + const affectedMorphs = [target, structurallyChangedMorph, ...owners].filter(Boolean); + if (affectedMorphs.some(affectedMorph => affectedMorph.isUsedAsEpiMorph())) return false; if (!this.targets.some(undoTarget => undoTarget === target || undoTarget.isAncestorOf(target))) return false; if (typeof filterFn === 'function') return filterFn(change); @@ -39,6 +174,14 @@ class Undo { this.recorder = null; } + joinTransaction (transaction) { + if (!(transaction instanceof EditTransaction)) { + throw new Error('Can only join EditTransaction instances'); + } + this.joinedTransactions.push(transaction); + return transaction; + } + apply () { if (!this.recorded()) { throw new Error('Cannot apply undo that has no changes recorded yet'); } this.changes.slice().forEach(change => change.apply()); @@ -60,6 +203,16 @@ class Undo { this.timestamp = undos[0].timestamp; this.no = undos[0].no; this.name = undos.map(({ name }) => name).join('-'); + this.label = this.name; + } + + canMergeWith (transaction) { + return transaction instanceof RecordedChangeTransaction; + } + + merge (transaction) { + this.addUndos([transaction]); + return this; } toString () { @@ -71,7 +224,7 @@ class Undo { selector ? `${target}.${selector}(${args.map(printArg)})` : `${target}.${prop} = ${printArg(value)}`).join('\n '); - return `Undo(${no}:${name} ${isRecording ? 'RECORDING ' : ''}${changesString})`; + return `RecordedChangeTransaction(${no}:${name} ${isRecording ? 'RECORDING ' : ''}${changesString})`; } } @@ -112,10 +265,24 @@ export class UndoManager { if (!this.grouping.current.length) return; const grouped = this.grouping.current.slice(1); - const undoGroup = this.grouping.current[0]; - undoGroup.addUndos(grouped); + const first = this.grouping.current[0]; + let undoGroup = first; + for (const transaction of grouped) { + undoGroup = undoGroup.canMergeWith(transaction) + ? undoGroup.merge(transaction) + : new CompositeEditTransaction( + undoGroup instanceof CompositeEditTransaction + ? undoGroup.transactions.concat(transaction) + : [undoGroup, transaction] + ); + } this.undos = arr.withoutAll(this.undos, grouped); + if (undoGroup !== first) { + const index = this.undos.indexOf(first); + this.undos[index] = undoGroup; + } this.grouping.current = []; + return undoGroup; } ensureNewGroup (morph, name = 'new undo group') { @@ -146,7 +313,11 @@ export class UndoManager { console.warn(`There is already an undo being recorded. Tried to start undo ${name} for ${morph.name}, but ${this.undoInProgress.name} is currently in progress.`); return; } - return this.undoInProgress = new Undo(name, [morph], this.counter++).startRecording(this.filter); + return this.undoInProgress = new RecordedChangeTransaction( + name, + [morph], + this.counter++ + ).startRecording(this.filter); } undoStop () { @@ -154,10 +325,37 @@ export class UndoManager { if (!undo) return null; undo.stopRecording(); this.undoInProgress = null; - this.undos.push(undo); - this.grouping.current.push(undo); + const transaction = undo.joinedTransactions.length + ? new CompositeEditTransaction([undo, ...undo.joinedTransactions], { + label: undo.label, + no: undo.no, + timestamp: undo.timestamp + }) + : undo; + return this.addTransaction(transaction); + } + + addTransaction (transaction, { group = true, joinActive = false } = {}) { + if (!(transaction instanceof EditTransaction)) { + throw new Error('UndoManager can only store EditTransaction instances'); + } + if (joinActive && this.undoInProgress) { + return this.undoInProgress.joinTransaction(transaction); + } + this.undos.push(transaction); + if (group) this.grouping.current.push(transaction); if (this.redos.length) this.redos.length = 0; - return undo; + return transaction; + } + + discardRecordedChanges (changes) { + const recorded = this.undoInProgress?.recorder?.changes; + if (!recorded) return 0; + const discarded = new Set(changes); + const retained = recorded.filter(change => !discarded.has(change)); + const removed = recorded.length - retained.length; + this.undoInProgress.recorder.changes = retained; + return removed; } removeLatestUndo () { @@ -171,9 +369,16 @@ export class UndoManager { const undo = this.removeLatestUndo(); if (!undo) return; arr.remove(this.grouping.current, undo); - this.redos.unshift(undo); this.applyCount++; - try { undo.reverseApply(); } finally { this.applyCount--; } + try { + undo.reverseApply(); + this.redos.unshift(undo); + } catch (error) { + this.undos.push(undo); + throw error; + } finally { + this.applyCount--; + } return undo; } @@ -181,9 +386,16 @@ export class UndoManager { this.undoStop(); const redo = this.redos.shift(); if (!redo) return; - this.undos.push(redo); this.applyCount++; - try { redo.apply(); } finally { this.applyCount--; } + try { + redo.apply(); + this.undos.push(redo); + } catch (error) { + this.redos.unshift(redo); + throw error; + } finally { + this.applyCount--; + } return redo; } diff --git a/lively.serializer2/plugins/expression-serializer.js b/lively.serializer2/plugins/expression-serializer.js index 17169a1286..2dd6d306e7 100644 --- a/lively.serializer2/plugins/expression-serializer.js +++ b/lively.serializer2/plugins/expression-serializer.js @@ -461,9 +461,12 @@ function handleTextAndAttributes (aMorph, exported, styleProto, path, masterInSc // all of the above work is then discarded basically... if (asExpression && exported.textAndAttributes) { // properly serialize some of the attributes such as fontColor - exported.textAndAttributes = getArrayExpression('textAndAttributes', aMorph.textAndAttributes.map(attr => { - return typeof attr === 'string' ? attr.replaceAll('\n', '\\n') : attr; - }), path, opts); + exported.textAndAttributes = getArrayExpression( + 'textAndAttributes', + aMorph.textAndAttributes, + path, + opts + ); } } diff --git a/lively.server/plugins/test-runner.js b/lively.server/plugins/test-runner.js index 4abdbb4e64..cddf2f83f6 100644 --- a/lively.server/plugins/test-runner.js +++ b/lively.server/plugins/test-runner.js @@ -3,6 +3,7 @@ import { promise } from 'lively.lang'; export default class TestRunner { async setup (livelyServer) { + this.baseURL = `http://localhost:${livelyServer.port}`; console.log('[Test Runner] Started'); } @@ -13,7 +14,7 @@ export default class TestRunner { while (true) { try { this.headlessSession = new HeadlessSession(); - await this.headlessSession.open('http://localhost:9011/worlds/load?name=__newWorld__&askForWorldName=false&fastLoad=false', (sess) => sess.runEval(`typeof $world !== 'undefined' && $world.isWorld && $world._uiInitialized`, { timeout: 5000 }).catch(() => false)); + await this.headlessSession.open(this.baseURL + '/worlds/load?name=__newWorld__&askForWorldName=false&fastLoad=false', (sess) => sess.runEval(`typeof $world !== 'undefined' && $world.isWorld && $world._uiInitialized`, { timeout: 5000 }).catch(() => false)); } catch (err) { if (attempts < 3) { attempts++; @@ -32,12 +33,19 @@ export default class TestRunner { const { localInterface } = await System.import("lively-system-interface"); const { loadPackage } = await System.import("lively-system-interface/commands/packages.js"); const packageToTestLoaded = localInterface.coreInterface.getPackages().find(pkg => pkg.name === '${module_to_test}'); + const packageAddress = packageToTestLoaded?.url || '${this.baseURL}/local_projects/${module_to_test}'; + const packageIsLocalProject = packageAddress.startsWith('${this.baseURL}/local_projects/'); if (!packageToTestLoaded){ await loadPackage(localInterface.coreInterface, { name: '${module_to_test}', - address: 'http://localhost:9011/local_projects/${module_to_test}', + address: packageAddress, type: 'package' }); + } else if (packageIsLocalProject) { + // Packages discovered during bootstrap can retain a stale cached + // package.json. Reload so current project-scoped import maps are active + // before the test files themselves are imported. + await localInterface.coreInterface.reloadPackage(packageAddress); } $world.handForPointerId(1); await System.import('mocha-es6/index.js');