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 lint from '../js/linter.js';
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 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] = 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;
// FIXME: cache the AST node and transform them with a source mods library that understands how to patch the ast
// This can be done with: import { print, parse } from 'esm://cache/recast@0.21.5'
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();
}
}