You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

306 lines
12 KiB
JavaScript

4 years ago
/* eslint-disable no-loop-func */
"use strict";
4 years ago
// Design note: We return stateLogs instead of passing in an object of registered handlers to call, because a node can become obsolete in mid-processing, and in those cases all of its state sets should be ignored. By far the easiest way to implement this, is to just keep a stateLog in the node handling context (since that entire context gets thrown away when processing gets aborted due to a subtree change), and let the parent deal with actually applying any still-relevant setStates to the correct handler functions.
4 years ago
// TODO: Figure out a way to track 'loss factor' per optimizer, ie. how many (partial or complete) node evaluations have been discarded due to the actions of that optimizer, including subtrees. This can give insight into which optimizers cause unreasonably much wasted work.
4 years ago
const util = require("util");
4 years ago
const splitFilter = require("split-filter");
const mapObj = require("map-obj");
const defaultValue = require("default-value");
4 years ago
const isPlainObj = require("is-plain-obj");
const findLast = require("find-last");
const NoChange = require("../../optimizers/util/no-change");
const RemoveNode = require("../../optimizers/util/remove-node");
4 years ago
const ConsumeNode = require("../../optimizers/util/consume-node");
const typeOf = require("../../type-of");
4 years ago
const concat = require("../../concat");
const deriveNode = require("../../derive-node");
const measureTime = require("../../measure-time");
4 years ago
const unreachable = require("../../unreachable");
4 years ago
const createHandlerTracker = require("./handler-tracker");
const createTimings = require("./timings-tracker");
const combineOptimizers = require("./combine-optimizers");
const createDebuggers = require("./create-debuggers");
4 years ago
// FIXME: Implement a scope tracker of some sort, to decouple the code here a bit more
4 years ago
// TODO: Determine if we can improve performance by avoiding a lot of array allocations for the path tracking; by eg. nesting objects instead and unpacking it into an array on-demand
// FIXME: Verify that the various iterations=0 arguments are actually correct, and don't lose iteration count metadata
4 years ago
let EVALUATION_LIMIT = 10;
4 years ago
function defer(func) {
return { __type: "defer", func: func };
}
4 years ago
function handleNodeChildren(node, handleASTNode, path) {
4 years ago
let changedProperties = {};
let stateLogs = [];
4 years ago
function tryTransformItem(node, path) {
if (node == null) {
return node;
} else if (node.__raqbASTNode === true) {
let result = handleASTNode(node, 0, path);
4 years ago
if (result.stateLog.length > 0) {
stateLogs.push(result.stateLog);
}
4 years ago
return result.node;
} else if (Array.isArray(node)) {
let valuesHaveChanged = false;
let transformedArray = node.map((value, i) => {
let pathSegment = { type: "$array", key: i };
let transformedValue = tryTransformItem(value, path.concat([ pathSegment ]));
4 years ago
4 years ago
if (transformedValue !== value) {
valuesHaveChanged = true;
}
return transformedValue;
});
4 years ago
if (valuesHaveChanged) {
return transformedArray;
} else {
return node;
}
4 years ago
} else if (isPlainObj(node)) {
let newObject = {};
let propertiesHaveChanged = false;
for (let [ key, value ] of Object.entries(node)) {
let pathSegment = { type: "$object", key: key };
let transformedValue = tryTransformItem(value, path.concat([ pathSegment ]));
4 years ago
if (transformedValue !== value) {
propertiesHaveChanged = true;
}
newObject[key] = transformedValue;
}
4 years ago
4 years ago
if (propertiesHaveChanged) {
return newObject;
} else {
return node;
4 years ago
}
} else {
// Probably some kind of literal value; we don't touch these.
4 years ago
return node;
}
}
// FIXME: Delete nulls?
for (let [ property, value ] of Object.entries(node)) {
let childPath = path.concat([{ type: node.type, key: property }]);
let transformedValue = tryTransformItem(value, childPath);
if (transformedValue !== value) {
changedProperties[property] = transformedValue;
}
}
4 years ago
return {
changedProperties: changedProperties,
stateLog: concat(stateLogs)
};
}
module.exports = function optimizeTree(ast, optimizers) {
4 years ago
let debuggers = createDebuggers(optimizers);
let visitors = combineOptimizers(optimizers);
let timings = createTimings(optimizers);
4 years ago
let visitorsByType = mapObj(visitors, (key, value) => {
return [
key,
concat([
defaultValue(value, []),
defaultValue(visitors["*"], []),
])
];
});
4 years ago
function handleASTNode(node, iterations = 0, path = [], initialStateLog) {
// console.log(path.map((item) => String(item.key)).join(" -> "));
4 years ago
// The stateLog contains a record of every setState call that was made during the handling of this node and its children. We keep a log for this rather than calling handlers directly, because setState calls should always apply to *ancestors*, not to the current node. That is, if the current node does a setState for `foo`, and also has a handler registered for `foo`, then that handler should not be called, but the `foo` handler in the *parent* node should be.
// FIXME: Scope stateLog entries by optimizer name? To avoid name clashes for otherwise similar functionality. Like when multiple optimizers track column names.
let stateLog = [];
let defers = [];
let handlers = createHandlerTracker();
let nodeVisitors = visitorsByType[node.type];
4 years ago
function handleResult({ debuggerName, result, permitDefer, initialStateLog }) {
4 years ago
if (result === NoChange) {
// no-op
} else if (result == null) {
4 years ago
// FIXME: Figure out a better way to indicate the origin of such an issue, than the current error message format?
// FIXME: Include information on which node this failed for
throw new Error(`[${debuggerName}] A visitor is not allowed to return null or undefined; if you intended to leave the node untouched, return a NoChange marker instead`);
4 years ago
} else if (result === RemoveNode) {
debuggers[debuggerName](`Node of type '${typeOf(node)}' removed`);
return { node: RemoveNode, stateLog: [] };
4 years ago
} else if (result === ConsumeNode) {
debuggers[debuggerName](`Node of type '${typeOf(node)}' consumed, but its stateLog was left intact`);
stateLog.forEach((item) => { item.isFromConsumedNode = true; }); // NOTE: Mutates!
return { node: ConsumeNode, stateLog: stateLog };
4 years ago
} else if (result.__type === "defer") {
if (permitDefer) {
debuggers[debuggerName](`Defer was scheduled for node of type '${typeOf(node)}'`);
defers.push({ debuggerName, func: result.func });
} else {
throw new Error(`Cannot schedule a defer from within a defer handler`);
}
} else if (result.__raqbASTNode === true) {
if (result === node) {
// Visitor returned the original node again; but in this case, it should return NoChange instead. We enforce this because after future changes to the optimizer implementation (eg. using an internally-mutable deep copy of the tree), we may no longer be able to *reliably* detect when the original node is returned; so it's best to already get people into the habit of returning a NoChange marker in those cases, by disallowing this.
throw new Error(`Visitor returned original node, but this may not work reliably; if you intended to leave the node untouched, return a NoChange marker instead`);
} else {
debuggers[debuggerName](`Node of type '${typeOf(node)}' replaced by node of type '${typeOf(result)}'`);
4 years ago
if (iterations >= EVALUATION_LIMIT) {
throw new Error(`Exceeded evaluation limit in optimizer ${debuggerName}; aborting optimization. If you are a user of raqb, please report this as a bug. If you are writing an optimizer, make sure that your optimizer eventually stabilizes on a terminal condition (ie. NoChange)!`);
} else {
4 years ago
return handleASTNode(result, iterations + 1, path, initialStateLog);
}
}
} else {
4 years ago
throw new Error(`Visitor returned an unexpected type of return value: ${util.inspect(result)}`);
}
}
4 years ago
function handleStateLog(newStateLog) {
let [ relevantState, otherState ] = splitFilter(newStateLog, (entry) => handlers.has(entry.name));
stateLog = stateLog.concat(otherState);
for (let item of relevantState) {
// FIXME: Log these, and which visitor they originate from
handlers.call(item.name, item.value);
}
}
4 years ago
function applyVisitorFunction({ visitorName, func, node, permitDefer }) {
let { value: result, time } = measureTime(() => {
return func(node, {
// eslint-disable-next-line no-loop-func
setState: (name, value) => {
// FIXME: util.inspect is slow, and not necessary when debug mode is disabled
debuggers[visitorName](`Setting state for '${name}' from node of type '${typeOf(node)}': ${util.inspect(value, { colors: true })}`);
stateLog.push({ name, value });
},
registerStateHandler: (name, func) => handlers.add(name, func),
4 years ago
defer: (permitDefer === true) ? defer : null,
findNearestStep: function (type) {
return (type != null)
? findLast(path, (item) => item.type === type)
: path[path.length - 1];
}
4 years ago
});
});
4 years ago
timings[visitorName] += time;
4 years ago
return result;
}
if (nodeVisitors != null) {
for (let visitor of nodeVisitors) {
let handled = handleResult({
debuggerName: visitor.name,
result: applyVisitorFunction({
visitorName: visitor.name,
func: visitor.func,
node: node,
permitDefer: true
}),
permitDefer: true
});
if (handled != null) {
// Handling of the current node was aborted
return handled;
}
}
}
4 years ago
4 years ago
let childResult = handleNodeChildren(node, handleASTNode, path);
4 years ago
if (Object.keys(childResult.changedProperties).length > 0) {
let newNode = deriveNode(node, childResult.changedProperties);
// We already know that the new node is a different one, but let's just lead it through the same handleResult process, for consistency. Handling of the pre-child-changes node is aborted here, and we re-evaluate with the new node.
4 years ago
let reevaluatedResult = handleResult({
4 years ago
debuggerName: "(subtree change)",
result: newNode,
4 years ago
permitDefer: false,
// NOTE: If we have any leftover state from nodes that were consumed upstream, we should make sure to include this in the reevaluation, even when the subtree was replaced!
initialStateLog: (childResult.stateLog.length > 0)
? childResult.stateLog.filter((item) => item.isFromConsumedNode)
: undefined
4 years ago
});
4 years ago
return reevaluatedResult;
4 years ago
}
4 years ago
if (initialStateLog != null) {
// NOTE: We intentionally process the initialStateLog here and not earlier; that way it is consistent with how any retained stateLog entries *would* have executed on the node before it got replaced (ie. after evaluation of the children). Conceptually you can think of it as the initialStateLog being prefixed to the stateLog of the childResult.
handleStateLog(initialStateLog);
}
4 years ago
4 years ago
if (childResult.stateLog.length > 0) {
handleStateLog(childResult.stateLog);
4 years ago
}
for (let defer of defers) {
let handled = handleResult({
debuggerName: `${defer.debuggerName} (deferred)`,
result: applyVisitorFunction({
visitorName: defer.debuggerName,
func: defer.func,
node: node,
permitDefer: false
}),
permitDefer: false
});
if (handled != null) {
// Handling of the current node was aborted
return handled;
}
}
4 years ago
4 years ago
return {
stateLog: stateLog,
node: node
};
}
4 years ago
let { value: rootResult, time } = measureTime(() => {
4 years ago
return handleASTNode(ast);
});
4 years ago
let timeSpentInOptimizers = Object.values(timings).reduce((sum, n) => sum + n, 0);
4 years ago
if (rootResult.node !== RemoveNode && rootResult.node !== ConsumeNode) {
return {
4 years ago
ast: rootResult.node,
timings: {
"# Total": time,
"# Walker overhead": time - timeSpentInOptimizers,
... timings,
}
};
} else {
unreachable("Root node was removed");
}
};