import { serializeSpec, ExpressionSerializer } from 'lively.serializer2';
import { serializeNestedProp } from 'lively.serializer2/plugins/expression-serializer.js';
import { Icons } from 'lively.morphic/text/icons.js';
import { arr, string, num, obj } from 'lively.lang';
import { parse, query } from 'lively.ast';
import { module } from 'lively.modules/index.js';
import lint from '../js/linter.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) {
const mod = module(modId);
if (!scope) scope = await mod.scope();
const componentDecls = [];
for (let decl of scope.varDecls) {
const varName = decl.declarations[0]?.id?.name; // better to use a source descriptor??
if (!varName) continue;
const val = mod.recorder[varName];
if (val?.isComponentDescriptor) {
componentDecls.push([val, decl]);
}
}
return componentDecls;
}
export function getEligibleSourceEditorsFor (modId, modSource) {
const qualifiedEditors = $world.withAllSubmorphsSelect(m => {
return m.isText && m.editorPlugin?.evalEnvironment?.targetModule === modId;
});
return qualifiedEditors.filter(editor => {
if (editor.owner.isBrowser && modSource) return !editor.owner.hasUnsavedChanges(modSource);
return true;
});
}
export function getPathFromScopeMaster (m) {
if (m.isComponent) return [];
return arr.takeWhile(m.ownerChain(), m => !m.isComponent).map(m => m.name);
}
export function getPathFromMorphToMaster (m) {
const path = [];
if (!m.isComponent) path.push(m.name);
path.push(...getPathFromScopeMaster(m));
return path;
}
/*************************
* EXPRESSION GENERATION *
*************************/
export function standardValueTransform (key, val, aMorph) {
if (val && val.isPoint) return val.roundTo(0.1);
if (key === 'label' || key === 'textAndAttributes') {
let hit;
if (Array.isArray(val) && (hit = Object.entries(Icons).find(([iconName, iconValue]) => iconValue.code === val[0]))) {
return {
__serialize__ () {
return {
__expr__: `Icon.textAttribute("${hit[0]}")`,
bindings: {
'lively.morphic/text/icons.js': ['Icon']
}
};
}
};
}
}
return val;
}
/**
* Converts a given morph to an expression object that preserves
* the component definition.
* @param { Morph } aMorph - The morph to convert to a expression.
* @param { object } opts - Custom options passed to the serialization. For more info, see `serlializeSpec()`.
* @returns { object } An expression object.
*/
export function convertToExpression (aMorph, opts = {}) {
const { __expr__: expr, bindings } = serializeSpec(aMorph, {
asExpression: true,
keepFunctions: false,
exposeMasterRefs: true,
dropMorphsWithNameOnly: true,
skipUnchangedFromDefault: true,
skipUnchangedFromMaster: true,
onlyIncludeStyleProps: true,
skipAttributes: DEFAULT_SKIPPED_ATTRIBUTES,
valueTransform: standardValueTransform,
...opts
}) || { __expr__: false };
if (!expr) return;
return {
bindings,
__expr__: `${expr.match(/^(morph|part)\(([^]*)\)/)?.[2] || expr}`
};
}
export function getTextAttributesExpr (textMorph) {
if (textMorph.textString === '') {
return { __expr__: '[\'\', null]', bindings: {} };
}
const expr = convertToExpression(textMorph);
const rootPropNode = getPropertiesNode(parse('(' + expr.__expr__ + ')')); // eslint-disable-line no-use-before-define
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);
return expr;
}
function indentExpression (expr, depth) {
const braceLength = 1;
const indentLength = depth * 2;
return string.indent(lint(`(${expr})`)[0], ' ', depth)
.slice(indentLength + braceLength, -braceLength - indentLength - 2);
}
/**
* Converts a certain value to a serializable expression. Requires a name of the property
* it belongs to, in order to properly convert nested properties.
* @param { string } prop - The name of the property.
* @param { * } value - The value of the property to serialize.
* @returns { object } Converted version of the property value as expression object.
*/
export function getValueExpr (prop, value, depth = 0) {
let valueAsExpr; let bindings = {};
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)})`;
bindings['lively.lang'] = ['num'];
}
if (prop === 'blur') {
value = `{ backdrop: ${value.backdrop ? 'true' : 'false'}, value: ${value.value} }`;
}
if (prop === 'imageUrl' && $world.openedProject && value.includes($world.openedProject.name)) {
value = value.replaceAll('"', '');
value = `projectAsset('${value.split('/').pop()}')`;
bindings['lively.project'] = ['projectAsset'];
}
if (prop === 'master' && value) {
valueAsExpr = value.getConfigAsExpression();
if (valueAsExpr) valueAsExpr.__expr__ = indentExpression(valueAsExpr.__expr__, depth);
return valueAsExpr;
}
if (prop === 'layout' && value) {
valueAsExpr = value.__serialize__();
valueAsExpr.__expr__ = indentExpression(valueAsExpr.__expr__, depth);
return valueAsExpr;
}
if (value && !value.isMorph && value.__serialize__) {
return value.__serialize__();
} else if (['borderColor', 'borderWidth', 'borderStyle', 'borderRadius'].includes(prop)) {
const nested = {};
value = serializeNestedProp(prop, value, {
exprSerializer, nestedExpressions: nested, asExpression: true
}, prop === 'borderRadius' ? ['topLeft', 'topRight', 'bottomLeft', 'bottomRight'] : ['top', 'left', 'right', 'bottom']);
value = obj.inspect(value, {}, depth);
for (let uuid in nested) {
const subExpr = nested[uuid];
value = value.replace(JSON.stringify(uuid), subExpr.__expr__);
Object.assign(bindings, subExpr.bindings);
}
}
valueAsExpr = {
__expr__: String(value),
bindings
};
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 *
******************/
/**
* Retrieve a property declaration from a properties nodes.
* @param { object } propsNode - The AST node of the properties object (spec) for a particular morph in a component definition.
* @param { string } prop - The name of the prop to retrieve.
* @returns { object|null } If present, the node the prop def.
*/
export function getProp (propsNode, prop) {
if (!propsNode) return null;
if (propsNode.type === 'CallExpression') {
propsNode = propsNode.arguments[0];
}
const [propNode] = query.queryNodes(propsNode, `
/ Property [
/:key Identifier [ @name == '${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.
* @param { string } componentName - The name of the component.
* @returns { object } The AST node of the component declarator.
*/
export function getComponentNode (parsedModuleContent, componentName) {
const [parsedComponent] = query.queryNodes(parsedModuleContent, `
// VariableDeclarator [
/:id Identifier [ @name == '${componentName}' ]
]`);
return parsedComponent;
}
/**
* Slight variation to `getComponentNode()`. Here we retrieve the component declaration,
* which means we include the declarator `component()` as well as the `const` variable
* the component is assigned to.
* @param { object } parsedModuleContent - The AST of the entire module where we look for the component definition.
* @param { string } componentName - The name of the component.
* @returns { object } The AST node of the component declaration
*/
export function findComponentDef (parsedModuleContent, componentName) {
return query.queryNodes(parsedModuleContent, `// VariableDeclaration [
@kind == "const"
&& /:declarations '*' [
VariableDeclarator [
/:id Identifier [ @name == "${componentName}"]
]
]
]`)[0];
}
/**
* Returns the AST node that containts the property attributes of a morph spec within a component definition.
* @param { object } parsedComponent - The parsed component.
* @param { Morph|string } aMorphOrName - A morph or name referencing the spec.
* @returns { object|null } The AST node of the parsed props object.
*/
export function getPropertiesNode (parsedComponent, aMorphOrName) {
// FIXME: use a name path instead of just the name, since a name alone ignore master scopes
// and can therefore easily resolve incorrectly
let name, aMorph;
if (!aMorphOrName || aMorphOrName.isComponent) {
return query.queryNodes(parsedComponent, `
.// ObjectExpression
`)[0];
}
if (obj.isString(aMorphOrName)) name = aMorphOrName;
else {
aMorph = aMorphOrName;
name = aMorph.name;
}
const morphDefs = query.queryNodes(parsedComponent, `
.// ObjectExpression [
/:properties "*" [
Property [
/:key Identifier [ @name == 'name' ]
&& /:value Literal [ @value == '${name}']
]
]
]
`);
return morphDefs[0];
}
function getNodeFromSubmorphs (submorphsNode, morphName) {
const [partOrAddRef] = query.queryNodes(submorphsNode, `
./ CallExpression [
/:callee Identifier [ @name == 'part' || @name == 'add' ]
&& /:arguments "*" [
CallExpression [
/:callee Identifier [ @name == 'part' ]
&& /:arguments "*" [
ObjectExpression [
/:properties "*" [
Property [
/:key Identifier [ @name == 'name' ]
&& /:value Literal [ @value == '${morphName}']
]
]
]
]
]
|| ObjectExpression [
/:properties "*" [
Property [
/:key Identifier [ @name == 'name' ]
&& /:value Literal [ @value == '${morphName}']
]
]
]
]
]
`);
if (partOrAddRef) return partOrAddRef; // FIXME: how can we express this in a single query?
const [propNode] = query.queryNodes(submorphsNode, `
./ ObjectExpression [
/:properties "*" [
Property [
/:key Identifier [ @name == 'name' ]
&& /:value Literal [ @value == '${morphName}']
]
]
]
`);
return propNode;
}
export function drillDownPath (startNode, path) {
// directly resolve step by step with a combo of a submorph/name prop resolution
if (path.length === 0) return startNode;
path = [...path]; // copy the path
let curr = startNode;
if (curr.type !== 'ArrayExpression') curr = getProp(curr, 'submorphs')?.value;
while (path.length > 0 && curr) {
const name = path.shift();
curr = getNodeFromSubmorphs(curr, name);
if (path.length > 0 && curr) {
if (curr.type !== 'ObjectExpression') { curr = getPropertiesNode(curr); }
curr = getProp(curr, 'submorphs')?.value;
} else break;
}
return curr;
}
/**
* Slight variation of getPropertiesNode().
* In cases where a derived morph is added to a component definition
* this function will retreive the AST node that *includes* the `part()` or `add()`
* call. This is useful when we want to remove this node entirely from a definition.
* (Just removing the node returned by getPropertiesNode() will result in empty `part()` or `add()`
* left over in the code).
* @param { object } parsedComponent - The parsed component definition wherein we look for the morph node.
* @param { Morph } aMorph - A morph object we use the name of to find the properties node in the definition.
* @returns { object|null } The AST node comprising the `part()`/`add()` call, if nessecary.
*/
export function getMorphNode (componentScope, aMorph) {
// often the morph node is just the properties node itself
// but when the morph is derived from another master component
// it is wrapped inside the part() call, which then needs to be returned
const path = getPathFromMorphToMaster(aMorph).reverse();
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;
let commaRemoved = false;
while (sourceCode[nodeToRemove.end].match(/\,/)) {
commaRemoved = true;
nodeToRemove.end++;
}
while (!sourceCode[nodeToRemove.start].match(/\,|\n/) &&
!sourceCode[nodeToRemove.start - 1].match(/\[/)) {
const aboutToRemoveCommaTwice = commaRemoved && sourceCode[nodeToRemove.start - 1].match(/\,/);
if (aboutToRemoveCommaTwice) break;
nodeToRemove.start--;
}
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 };