Files
leporello-js/src/calltree.js
Dmitry Vasilev af6cec2505 fix
2022-11-08 19:51:52 +08:00

866 lines
21 KiB
JavaScript

import {map_accum, map_find, map_object, stringify, findLast} from './utils.js'
import {is_eq, find_error_origin_node} from './ast_utils.js'
import {find_node, find_leaf, ancestry_inc} from './ast_utils.js'
import {color} from './color.js'
import {eval_frame} from './eval.js'
export const pp_calltree = calltree => stringify(map_object(
calltree,
(module, {exports, calls}) => do_pp_calltree(calls)
))
export const do_pp_calltree = tree => ({
id: tree.id,
ok: tree.ok,
is_log: tree.is_log,
has_more_children: tree.has_more_children,
string: tree.code?.string,
children: tree.children && tree.children.map(do_pp_calltree)
})
const find_calltree_node = (state, id) => {
return find_node(
{
children: [
root_calltree_node(state),
{children: state.async_calls},
]
},
n => n.id == id
)
}
const is_stackoverflow = node =>
// Chrome
node.error.message == 'Maximum call stack size exceeded'
||
// Firefox
node.error.message == "too much recursion"
export const calltree_node_loc = node => node.toplevel
? {module: node.module}
: node.fn.__location
// Finds the last module that was evaluated. If all modules evaluated without
// problems, then it is the entrypoint module. Else it is the module that
// throws error
export const root_calltree_module = state =>
findLast(
state.parse_result.sorted,
module => state.calltree[module] != null
)
export const root_calltree_node = state =>
state.calltree[root_calltree_module(state)].calls
export const is_native_fn = calltree_node =>
!calltree_node.toplevel && calltree_node.fn.__location == null
export const active_frame = state =>
state.frames[state.active_calltree_node.id]
const get_calltree_node_by_loc = (state, node) =>
state.calltree_node_by_loc
?.[state.current_module]
?.[
state.parse_result.modules[state.current_module] == node
// identify toplevel by index `-1`, because function and toplevel can
// have the same index (in case when module starts with function_expr)
? -1
: node.index
]
const add_calltree_node_by_loc = (state, loc, node_id) => {
return {
...state,
calltree_node_by_loc:
{...state.calltree_node_by_loc,
[loc.module]: {
...state.calltree_node_by_loc?.[loc.module],
[loc.index ?? -1]: node_id
}
}
}
}
export const set_active_calltree_node = (
state,
active_calltree_node,
current_calltree_node = state.current_calltree_node,
) => {
const result = {
...state,
active_calltree_node,
current_calltree_node,
}
// TODO currently commented, required to implement livecoding second and
// subsequent fn calls
/*
// Record last_good_state every time active_calltree_node changes
return {...result, last_good_state: result}
*/
return result
}
export const add_frame = (
state,
active_calltree_node,
current_calltree_node = active_calltree_node,
) => {
let with_frame
if(state.frames?.[active_calltree_node.id] == null) {
const frame = eval_frame(active_calltree_node, state.calltree)
const coloring = color(frame)
with_frame = {...state,
frames: {...state.frames,
[active_calltree_node.id]: {...frame, coloring}
}
}
} else {
with_frame = state
}
const result = add_calltree_node_by_loc(
with_frame,
calltree_node_loc(active_calltree_node),
active_calltree_node.id,
)
return set_active_calltree_node(result, active_calltree_node, current_calltree_node)
}
const replace_calltree_node = (root, node, replacement) => {
const do_replace = root => {
if(root.id == node.id) {
return [true, replacement]
}
if(root.children == null) {
return [false, root]
}
const [replaced, children] = map_accum(
(replaced, c) => replaced
// Already replaced, do not look for replacement
? [true, c]
: do_replace(c),
false,
root.children,
)
if(replaced) {
return [true, {...root, children}]
} else {
return [false, root]
}
}
const [replaced, result] = do_replace(root)
if(!replaced) {
throw new Error('illegal state')
}
return result
}
const replace_calltree_node_in_state = (state, node, replacement) => {
const root = root_calltree_module(state)
// Update calltree, replacing node with expanded node
const {exports, calls} = state.calltree[root]
const replaced = replace_calltree_node(
{
children: [
calls,
{children: state.async_calls},
]
},
node,
replacement
)
const calltree = {...state.calltree,
[root]: {
exports,
calls: replaced.children[0],
}
}
return {...state, calltree, async_calls: replaced.children[1].children}
}
const expand_calltree_node = (state, node) => {
if(node.has_more_children) {
const next_node = state.calltree_actions.expand_calltree_node(node)
return {
state: replace_calltree_node_in_state(state, node, next_node),
node: next_node
}
} else {
return {state, node}
}
}
const jump_calltree_node = (_state, _current_calltree_node) => {
const {state, node: current_calltree_node} = expand_calltree_node(
_state, _current_calltree_node
)
/*
When node is selected or expanded/collapsed
If native, goto call site
If hosted
If parent is native
goto inside fn
If parent is hosted
If expanded, goto inside fn
If collapsed, goto call site
*/
/* Whether to show fn body (true) or callsite (false) */
let show_body
const [_parent] = path_to_root(
{
children: [
root_calltree_node(state),
{children: state.async_calls},
]
},
current_calltree_node
)
const parent = _parent.id == null
? current_calltree_node
: _parent
if(
current_calltree_node.toplevel
||
/* async call */
parent == current_calltree_node
) {
show_body = true
} else if(is_native_fn(current_calltree_node)) {
show_body = false
} else {
if(is_native_fn(parent)) {
show_body = true
} else {
const is_expanded = state.calltree_node_is_expanded[current_calltree_node.id]
show_body = is_expanded
}
}
const active_calltree_node = show_body ? current_calltree_node : parent
const next = add_frame(state, active_calltree_node, current_calltree_node)
const loc = show_body
? calltree_node_loc(next.active_calltree_node)
: find_callsite(next.calltree, active_calltree_node, current_calltree_node)
return {
state: {...next, current_module: loc.module},
effects: next.current_calltree_node.toplevel
? {type: 'unembed_value_explorer'}
: [
{
type: 'set_caret_position',
// TODO: better jump not start of function (arguments), but start
// of body?
args: [loc.index],
},
{
type: 'embed_value_explorer',
args: [{
index: loc.index,
result: {
ok: true,
value: current_calltree_node.ok
? {
'*arguments*': current_calltree_node.args,
'*return*': current_calltree_node.value,
}
: {
'*arguments*': current_calltree_node.args,
'*throws*': current_calltree_node.error,
}
}
}],
},
]
}
}
export const path_to_root = (root, child) => {
const do_path = (root) => {
if(root.id == child.id) {
return []
}
if(root.children == null) {
return null
}
return root.children.reduce(
(result, c) => {
if(result != null) {
return result
}
const path = do_path(c)
if(path == null) {
return null
}
return [...path, root]
},
null
)
}
const result = do_path(root)
if(result == null) {
throw new Error('illegal state')
}
return result
}
export const is_expandable = node =>
// Hosted node always can be expanded, even if has not children
// Toplevel cannot be expanded if has no children
(!is_native_fn(node) && !node.toplevel)
||
(node.children != null || node.has_more_children)
/*
Right -
- does not has children - nothing
- has children - first click expands, second jumps to first element
Left -
- root - nothing
- not root collapse node, goes to parent if already collapsed
Up - goes to prev visible element
Down - goes to next visible element
Click - select and toggle expand
step_into - select and expand
*/
const arrow_down = state => {
const current = state.current_calltree_node
let next_node
if(
is_expandable(current)
&& state.calltree_node_is_expanded[current.id]
&& current.children != null
) {
next_node = current.children[0]
} else {
const next = (n, path) => {
if(n == root_calltree_node(state)) {
return null
}
const [parent, ...grandparents] = path
const child_index = parent.children.findIndex(c =>
c == n
)
const next_child = parent.children[child_index + 1]
if(next_child == null) {
return next(parent, grandparents)
} else {
return next_child
}
}
next_node = next(
current,
path_to_root(root_calltree_node(state), current)
)
}
return next_node == null
? state
: jump_calltree_node(state, next_node)
}
const arrow_up = state => {
const current = state.current_calltree_node
if(current == root_calltree_node(state)) {
return state
}
const [parent] = path_to_root(root_calltree_node(state), current)
const child_index = parent.children.findIndex(c =>
c == current
)
const next_child = parent.children[child_index - 1]
let next_node
if(next_child == null) {
next_node = parent
} else {
const last = node => {
if(
!is_expandable(node)
|| !state.calltree_node_is_expanded[node.id]
|| node.children == null
) {
return node
} else {
return last(node.children[node.children.length - 1])
}
}
next_node = last(next_child)
}
return jump_calltree_node(state, next_node)
}
const arrow_left = state => {
const current = state.current_calltree_node
const is_expanded = state.calltree_node_is_expanded[current.id]
if(!is_expandable(current) || !is_expanded) {
if(current != root_calltree_node(state)) {
const [parent] = path_to_root(root_calltree_node(state), current)
return jump_calltree_node(state, parent)
} else {
return state
}
} else {
return toggle_expanded(state)
}
}
const arrow_right = state => {
const current = state.current_calltree_node
if(is_expandable(current)) {
const is_expanded = state.calltree_node_is_expanded[current.id]
if(!is_expanded) {
return toggle_expanded(state)
} else {
if(current.children != null) {
return jump_calltree_node(state, current.children[0])
} else {
return state
}
}
} else {
return state
}
}
const find_callsite = (calltree, parent, node) => {
const frame = eval_frame(parent, calltree)
const result = find_node(frame, n => n.result?.call == node)
return {module: calltree_node_loc(parent).module, index: result.index}
}
export const toggle_expanded = (state, is_exp) => {
const node_id = state.current_calltree_node.id
const prev = state.calltree_node_is_expanded[node_id]
const next_is_exp = is_exp ?? !prev
const expanded_state = {
...state,
calltree_node_is_expanded: {
...state.calltree_node_is_expanded,
[node_id]: next_is_exp,
}
}
return jump_calltree_node(
expanded_state,
state.current_calltree_node,
)
}
const click = (state, id) => {
const node = find_calltree_node(state, id)
const {state: nextstate, effects} = jump_calltree_node(state, node)
if(is_expandable(node)) {
// `effects` are intentionally discarded, correct `set_caret_position` will
// be applied in `toggle_expanded`
return toggle_expanded(nextstate)
} else {
return {state: nextstate, effects}
}
}
/*
After find_call, we have two calltrees that have common subtree (starting from
root node). Nodes in these subtrees are the same, but have different ids. Copy
nodes that are in the second tree that are not in the first tree
*/
const merge_calltrees = (prev, next) => {
return Object.fromEntries(
Object.entries(prev).map(([module, {is_external, exports, calls}]) =>
[
module,
is_external
? {is_external, exports}
: {
exports,
calls: merge_calltree_nodes(calls, next[module].calls)[1]
}
]
)
)
}
const merge_calltree_nodes = (a, b) => {
// TODO Quick workaround, should be fixed by not saving stack in eval.js in
// case of stackoverflow
if(!a.ok && is_stackoverflow(a)) {
return [false, a]
}
if(a.has_more_children) {
if(b.has_more_children) {
return [false, a]
} else {
// Preserve id
return [true, {...b, id: a.id}]
}
}
if(a.children == null) {
return [false, a]
}
if(b.has_more_children) {
return [false, a]
}
const [has_update, children] = map_accum(
(has_update, c, i) => {
const [upd, merged] = merge_calltree_nodes(c, b.children[i])
return [has_update || upd, merged]
},
false,
a.children
)
if(has_update) {
return [true, {...a, children}]
} else {
return [false, a]
}
}
/*
Finds node in calltree `a` that has the same position that node with id `id`
in calltree `b`. Null if not found
*/
const find_same_node = (a, b, id) => {
return map_find(
Object.entries(a),
([module, {calls}]) => do_find_same_node(calls, b[module].calls, id)
)
}
const do_find_same_node = (a, b, id) => {
if(b == null) {
return null
}
if(b.id == id) {
return a
}
if(a.children == null || b.children == null) {
return null
}
return map_find(
a.children,
(c, i) => do_find_same_node(c, b.children[i], id)
)
}
export const expand_path = (state, node) => ({
...state,
calltree_node_is_expanded: {
...state.calltree_node_is_expanded,
...Object.fromEntries(
path_to_root(root_calltree_node(state), node)
.map(n => [n.id, true])
),
// Also expand node, since it is not included in
// path_to_root
[node.id]: true,
}
})
export const initial_calltree_node = state => {
const root = root_calltree_node(state)
if(
root.ok
||
// Not looking for error origin, stack too deep
is_stackoverflow(root)
) {
return {
state: expand_path(state, root),
node: root,
}
} else {
// Find error origin
const node = find_node(root,
n => !n.ok && (
// All children are ok
n.children == null
||
n.children.find(c => !c.ok) == null
)
)
return {state: expand_path(state, node), node}
}
}
export const default_expand_path = state => initial_calltree_node(state).state
export const find_call_node = (state, index) => {
const module = state.parse_result.modules[state.current_module]
if(module == null) {
// Module is not executed
return null
}
let node
if(index < module.index || index >= module.index + module.length) {
// index is outside of module, it can happen because of whitespace and
// comments in the beginning and the end
node = module
} else {
const leaf = find_leaf(module, index)
const anc = ancestry_inc(leaf, module)
const fn = anc.find(n => n.type == 'function_expr')
node = fn == null
? module
: fn
}
return node
}
export const find_call = (state, index) => {
const node = find_call_node(state, index)
if(node == null) {
return state
}
if(state.active_calltree_node != null && is_eq(node, state.active_calltree_node.code)) {
return state
}
const ct_node_id = get_calltree_node_by_loc(state, node)
if(ct_node_id === null) {
// strict compare (===) with null, to check if we put null earlier to
// designate that fn is not reachable
return set_active_calltree_node(state, null)
}
if(ct_node_id != null) {
const ct_node = find_node(
root_calltree_node(state),
n => n.id == ct_node_id
)
if(ct_node == null) {
throw new Error('illegal state')
}
return set_active_calltree_node(state, ct_node, ct_node)
}
if(node == state.parse_result.modules[root_calltree_module(state)]) {
const toplevel = root_calltree_node(state)
return add_frame(
expand_path(
state,
toplevel
),
toplevel,
)
} else if(node.type == 'do') {
// Currently we only allow to eval in toplevel of entrypoint module
return state
}
const loc = {index: node.index, module: state.current_module}
const {calltree, call} = state.calltree_actions.find_call(
root_calltree_module(state),
loc
)
if(call == null) {
return add_calltree_node_by_loc(
// Remove active_calltree_node
// current_calltree_node may stay not null, because it is calltree node
// explicitly selected by user in calltree view
set_active_calltree_node(state, null),
loc,
null
)
}
const merged = merge_calltrees(state.calltree, calltree)
const active_calltree_node = find_same_node(merged, calltree, call.id)
return add_frame(
expand_path(
{...state, calltree: merged},
active_calltree_node
),
active_calltree_node,
)
}
const select_return_value = state => {
if(state.current_calltree_node.toplevel) {
return {state}
}
const code = state.active_calltree_node.code
const loc = calltree_node_loc(state.active_calltree_node)
const frame = active_frame(state)
let node, result_node
if(state.current_calltree_node == state.active_calltree_node) {
if(frame.result.ok) {
if(code.body.type == 'do') {
const return_statement = find_node(frame, n =>
n.type == 'return' && n.result?.ok
)
if(return_statement == null) {
// Fn has no return statement
return {
state: {...state, current_module: loc.module},
effects: {type: 'set_caret_position', args: [code.body.index, true]}
}
} else {
result_node = return_statement.children[0]
}
} else {
// Last children is function body expr
result_node = frame.children[frame.children.length - 1]
}
} else {
result_node = find_error_origin_node(frame)
}
node = find_node(code, n => is_eq(result_node, n))
} else {
result_node = find_node(frame, n =>
n.type == 'function_call'
&& n.result != null
&& n.result.call.id == state.current_calltree_node.id
)
node = find_node(code, n => is_eq(result_node, n))
}
return {
state: {...state,
current_module: loc.module,
selection_state: {
node,
initial_is_expand: true,
result: result_node.result,
}
},
effects: {type: 'set_caret_position', args: [node.index, true]}
}
}
const select_arguments = (state, with_focus = true) => {
if(state.current_calltree_node.toplevel) {
return {state}
}
const loc = calltree_node_loc(state.active_calltree_node)
const frame = active_frame(state)
let node, result
if(state.current_calltree_node == state.active_calltree_node) {
if(state.active_calltree_node.toplevel) {
return {state}
}
node = state.active_calltree_node.code.children[0] // function_args
result = frame.children[0].result
} else {
const call = find_node(frame, n =>
n.type == 'function_call'
&& n.result != null
&& n.result.call.id == state.current_calltree_node.id
)
const call_node = find_node(state.active_calltree_node.code, n => is_eq(n, call))
node = call_node.children[1] // call_args
result = call.children[1].result
}
return {
state: {...state,
current_module: loc.module,
selection_state: {
node,
initial_is_expand: true,
result,
}
},
effects: {type: 'set_caret_position', args: [node.index, with_focus]}
}
}
const navigate_logs_increment = (state, increment) => {
if(state.logs.logs.length == 0) {
return {state}
}
const index =
Math.max(
Math.min(
state.logs.log_position == null
? 0
: state.logs.log_position + increment,
state.logs.logs.length - 1
),
0
)
return navigate_logs_position(state, index)
}
const navigate_logs_position = (state, log_position) => {
const node = find_node(
root_calltree_node(state),
n => n.id == state.logs.logs[log_position].id
)
const {state: next, effects} = select_arguments(
expand_path(jump_calltree_node(state, node).state, node),
false,
)
return {
state: {...next, logs: {...state.logs, log_position}},
effects,
}
}
export const calltree_commands = {
arrow_down,
arrow_up,
arrow_left,
arrow_right,
click,
select_return_value,
select_arguments,
navigate_logs_position,
navigate_logs_increment,
}