This commit is contained in:
dmitry-vsl
2024-12-12 14:54:01 +00:00
parent 0a26ac6fa5
commit f03d97ee1e
52 changed files with 99 additions and 17483 deletions

View File

@@ -1,22 +0,0 @@
{
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"env": {
"node": true,
"browser": true,
"es6": true
},
"rules": {
"no-unsafe-finally": "off",
"no-unused-vars": "off",
"no-use-before-define": [2, { "functions": false , "classes": false, "variables": false}],
"no-console": "off"
},
"globals": {
"globalThis": true
},
"extends": "eslint:recommended"
}

3
.github/FUNDING.yml vendored
View File

@@ -1,3 +0,0 @@
# These are supported funding model platforms
github: [leporello-js]

View File

@@ -1,44 +0,0 @@
# Simple workflow for deploying static content to GitHub Pages
name: Deploy static content to Pages
on:
# Runs on pushes targeting the default branch
push:
branches: ["master"]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
# Single deploy job since we're just deploying
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Build
run: ./gen_module_preload.sh
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: '.'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

@@ -1,25 +0,0 @@
name: Test
on:
push:
branches: '*'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v3
with:
node-version: 'latest'
- name: test
run: |
npm install eslint
npx eslint src test
node test/run.js
node test/self_hosted_test.js

2
.gitignore vendored
View File

@@ -1,2 +0,0 @@
README.html
.DS_Store

0
.nojekyll Normal file
View File

View File

@@ -1,6 +1,6 @@
# Leporello.js # Leporello.js
Leporello.js is an interactive JavaScript environment with a time-travel debugger Leporello.js is a next-level online JavaScript debugger and REPL
[<img src="docs/images/video_cover.png" width="600px">](https://vimeo.com/845773267) [<img src="docs/images/video_cover.png" width="600px">](https://vimeo.com/845773267)
@@ -32,7 +32,7 @@ Leporello.js source code is developed within Leporello.js itself
Variables are declared using the `const` or 'let' declaration. The use of `var` is not supported. Variables are declared using the `const` or 'let' declaration. The use of `var` is not supported.
Loops of any kind are not supported. Instead, consider using recursion or array functions as alternatives. Currently the only supported loop type is `while`. Other loop types (for loop, for-of loop, for-in loop, do-while loop) will be supported in future.
The `if` / `else` statements can only contain blocks of code and not single statements (TODO). The `if` / `else` statements can only contain blocks of code and not single statements (TODO).

View File

@@ -0,0 +1,35 @@
window.addEventListener('load', () => {
const text = document.createElement('input')
const checkbox = document.createElement('input')
checkbox.setAttribute('type', 'checkbox')
const radio = document.createElement('input')
radio.setAttribute('type', 'radio')
const range = document.createElement('input')
range.setAttribute('type', 'range')
const select = document.createElement('select')
Array.from({length: 5}, (_, i) => i).forEach(i => {
const option = document.createElement('option')
option.setAttribute('value', i)
option.innerText = i
select.appendChild(option)
})
const div = document.createElement('div')
const elements = { text, checkbox, range, select, radio, div}
Object.entries(elements).forEach(([name, el]) => {
document.body.appendChild(el);
['click', 'input', 'change'].forEach(type => {
el.addEventListener(type, e => {
const row = document.createElement('div')
div.appendChild(row)
row.innerText = [name, type, e.target.value, e.target.checked, e.target.selectedIndex].join(', ')
})
})
})
})

View File

@@ -1,6 +0,0 @@
PRELOADS=""
for f in `find src -name '*.js'`; do
PRELOADS=$PRELOADS"<link rel='modulepreload' href='/$f'>"
done
sed -i.bak "s#.*PRELOADS_PLACEHOLDER.*#$PRELOADS#" index.html

View File

@@ -5,8 +5,6 @@
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/> <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<title>Leporello.js</title> <title>Leporello.js</title>
<!--PRELOADS_PLACEHOLDER-->
<script src='ace/ace.js'></script> <script src='ace/ace.js'></script>
<script src='ace/keybinding-vim.js'></script> <script src='ace/keybinding-vim.js'></script>
<script src='ace/ext-language_tools.js'></script> <script src='ace/ext-language_tools.js'></script>
@@ -18,6 +16,8 @@
:root { :root {
--shadow_color: rgb(171 200 214); --shadow_color: rgb(171 200 214);
--active_color: rgb(173, 228, 253); --active_color: rgb(173, 228, 253);
--error-color: #ff000024;
--warn-color: #fff6d5;
} }
html, body, .app { html, body, .app {
@@ -28,6 +28,25 @@
margin: 0px; margin: 0px;
} }
.spinner {
display: inline-block;
height: 0.8em;
width: 0.8em;
min-width: 0.8em;
border-radius: 50%;
border-top: none !important;
border: 2px solid;
animation: rotate 0.6s linear infinite;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.app { .app {
/* same as ace editor */ /* same as ace editor */
font-family: Monaco, Menlo, "Ubuntu Mono", Consolas, source-code-pro, monospace; font-family: Monaco, Menlo, "Ubuntu Mono", Consolas, source-code-pro, monospace;
@@ -81,6 +100,7 @@
.selection { .selection {
position: absolute; position: absolute;
background-color: #ff00ff; background-color: #ff00ff;
z-index: 1; /* make it on top of evaluated_ok and evaluated_error */
} }
.evaluated_ok { .evaluated_ok {
@@ -89,7 +109,7 @@
} }
.evaluated_error { .evaluated_error {
position: absolute; position: absolute;
background-color: #ff000024; background-color: var(--error-color);
} }
.error-code { .error-code {
/* /*
@@ -157,7 +177,19 @@
} }
.logs .log.active { .logs .log.active {
background-color: var(--active_color); background-color: var(--active_color) !important;
}
.logs .log.error {
background-color: var(--error-color);
color: black !important; /* override red color that is set for calltree */
&.native {
color: grey !important;
}
}
.logs .log.warn {
background-color: var(--warn-color);
} }
.tab_content { .tab_content {
@@ -166,12 +198,25 @@
} }
.callnode { .callnode {
/* This makes every callnode be the size of the the longest one, so
* every callnode is clickable anywhere in the calltree view, and
* background for active callnodes is as wide as the entire container.
* Useful when scrolling very wide call trees */
min-width: fit-content;
margin-left: 1em; margin-left: 1em;
} }
.callnode .active { .callnode .active {
background-color: var(--active_color); background-color: var(--active_color);
} }
.call_el { .call_el {
/*
Make active callnode background start from the left of the calltree
view
*/
margin-left: -1000vw;
padding-left: 1000vw;
width: 100%;
cursor: pointer; cursor: pointer;
display: inline-block; display: inline-block;
} }
@@ -179,6 +224,7 @@
padding-left: 5px; padding-left: 5px;
padding-right: 2px; padding-right: 2px;
} }
.call_header { .call_header {
white-space: nowrap; white-space: nowrap;
} }
@@ -187,7 +233,6 @@
} }
.call_header.error.native { .call_header.error.native {
color: red; color: red;
opacity: 0.5;
} }
.call_header.native { .call_header.native {
font-style: italic; font-style: italic;
@@ -318,9 +363,15 @@
} }
.value_explorer_header { .value_explorer_header {
display: inline-block;
padding-right: 1em;
cursor: pointer; cursor: pointer;
} }
.value_explorer_header .expand_icon {
padding: 5px;
}
.value_explorer_header.active { .value_explorer_header.active {
background-color: rgb(148, 227, 191); background-color: rgb(148, 227, 191);
} }
@@ -340,6 +391,11 @@
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
} }
.statusbar .spinner {
margin-right: 0.5em;
}
.status, .current_file { .status, .current_file {
font-size: 1.5em; font-size: 1.5em;
} }

View File

@@ -1,3 +0,0 @@
{
"type" : "module"
}

View File

@@ -50,7 +50,7 @@ const serve_response_from_dir = async event => {
let file let file
if(path == '__leporello_blank.html') { if(path == '__leporello_blank.html') {
file = '' file = '<!doctype html>'
} else if(dir_handle != null) { } else if(dir_handle != null) {
file = await read_file(dir_handle, path) file = await read_file(dir_handle, path)
} else { } else {

View File

@@ -1,153 +0,0 @@
import {collect_destructuring_identifiers} from './ast_utils.js'
const set_versioned_let_vars = (node, closed_let_vars, assigned_vars) => {
if(
node.type == 'identifier'
&& closed_let_vars.find(index => node.index == index) != null
&& assigned_vars.find(index => node.index == index) != null
) {
return {...node, is_versioned_let_var: true}
} else if(node.children != null) {
return {
...node,
children: node.children.map(c =>
set_versioned_let_vars(c, closed_let_vars, assigned_vars)
)
}
} else {
return node
}
}
const do_find_versioned_let_vars = (node, current_fn) => {
const children_result = node
.children
.map(c => find_versioned_let_vars(c, current_fn))
const children = children_result.map(r => r.node)
const closed_let_vars = children_result
.flatMap(r => r.closed_let_vars)
.filter(r => r != null)
const assigned_vars = children_result
.flatMap(r => r.assigned_vars)
.filter(r => r != null)
return {
node: {...node, children},
closed_let_vars,
assigned_vars,
}
}
const has_versioned_let_vars = (node, is_root = true) => {
if(node.type == 'identifier' && node.is_versioned_let_var) {
return true
} else if(node.type == 'function_expr' && !is_root) {
return false
} else if(node.children != null) {
return node.children.find(c => has_versioned_let_vars(c, false)) != null
} else {
return false
}
}
// TODO function args
export const find_versioned_let_vars = (node, current_fn = node) => {
/*
Assigns 'is_versioned_let_var: true' to let variables that are
- assigned after declaration
- closed in nested function
and sets 'has_versioned_let_vars: true' for functions that have versioned
let vars.
- Traverse AST
- collects closed_let_vars and assigned_vars going from AST bottom to root:
- For every assignment, add assigned var to assigned_vars
- For every use of identifier, check if it used in function where it
declared, and populate assigned_vars otherwise
- for 'do' node, find 'let' declarations and set is_versioned_let_var.
*/
if(node.type == 'do') {
const {node: result, closed_let_vars, assigned_vars}
= do_find_versioned_let_vars(node, current_fn)
const next_node = {
...result,
children: result.children.map(c => {
if(c.type != 'let') {
return c
} else {
const children = c.children.map(decl => {
if(decl.type == 'identifier') {
return set_versioned_let_vars(decl, closed_let_vars, assigned_vars)
} else if(decl.type == 'decl_pair') {
const [left, right] = decl.children
return {
...decl,
children: [
set_versioned_let_vars(left, closed_let_vars, assigned_vars),
right
]
}
} else {
throw new Error('illegal state')
}
})
return {...c, children}
}
})
}
return {
node: node == current_fn
// toplevel
? {...next_node, has_versioned_let_vars: has_versioned_let_vars(next_node)}
: next_node,
closed_let_vars,
assigned_vars,
}
} else if(node.type == 'assignment') {
const {node: next_node, closed_let_vars, assigned_vars}
= do_find_versioned_let_vars(node, current_fn)
const next_assigned_vars = node
.children
.filter(c => c.type == 'decl_pair')
.flatMap(decl_pair =>
collect_destructuring_identifiers(decl_pair).map(id => {
if(id.definition.index == null) {
throw new Error('illegal state')
}
return id.definition.index
})
)
return {
node: next_node,
closed_let_vars,
assigned_vars: [...(assigned_vars ?? []), ...next_assigned_vars],
}
} else if(node.type == 'function_expr') {
const result = do_find_versioned_let_vars(node, node)
return {
...result,
node: {
...result.node,
has_versioned_let_vars: has_versioned_let_vars(result.node)
}
}
} else if(node.children != null) {
return do_find_versioned_let_vars(node, current_fn)
} else if(node.type == 'identifier') {
if(node.definition == 'self') {
return {node, closed_let_vars: null, assigned_vars: null}
}
const index = node.definition.index
if(!(index >= current_fn.index && index < current_fn.index + current_fn.length)) {
// used let var from parent function scope
return {
node,
closed_let_vars: [index],
assigned_vars: null,
}
} else {
return {node, closed_let_vars: null, assigned_vars: null}
}
} else if(node.children == null) {
return {node, closed_let_vars: null, assigned_vars: null}
}
}

View File

@@ -1,208 +0,0 @@
import {uniq, map_find} from './utils.js'
export const collect_destructuring_identifiers = node => {
if(Array.isArray(node)) {
return node.map(collect_destructuring_identifiers).flat()
} else if(node.type == 'identifier') {
return [node]
} else if(['destructuring_default', 'destructuring_rest'].includes(node.type)){
return collect_destructuring_identifiers(node.name_node)
} else if(node.type == 'destructuring_pair') {
return collect_destructuring_identifiers(node.value)
} else if(
['array_destructuring', 'object_destructuring', 'function_args']
.includes(node.type)
) {
return node.children
.map(collect_destructuring_identifiers)
.flat()
} else if (node.type == 'decl_pair') {
return collect_destructuring_identifiers(node.children[0])
} else {
console.error(node)
throw new Error('not implemented')
}
}
export const map_destructuring_identifiers = (node, mapper) => {
const map = node => map_destructuring_identifiers(node, mapper)
if(node.type == 'identifier') {
return mapper(node)
} else if(node.type == 'destructuring_default') {
return {...node, children: [map(node.children[0]), node.children[1]]}
} else if(node.type == 'destructuring_rest') {
return {...node, children: [mapper(node.children[0])]}
} else if(node.type == 'destructuring_pair') {
return {...node, children: [node.children[0], map(node.children[1])]}
} else if(node.type == 'array_destructuring' || node.type == 'object_destructuring') {
return {...node, children: node.children.map(map)}
} else {
console.error(node)
throw new Error('not implemented')
}
}
export const collect_imports = module => {
return uniq(
module.children
.filter(n => n.type == 'import')
.filter(n => !n.is_external)
.map(n => n.full_import_path)
)
}
export const collect_external_imports = modules =>
Object
.entries(modules)
.map(([module_name, node]) =>
node
.children
.filter(c => c.type == 'import' && c.is_external)
.map(node => ({node, module_name}))
)
.flat()
export const find_leaf = (node, index) => {
if(!(node.index <= index && node.index + node.length > index)){
return null
} else {
if(node.children == null){
return node
} else {
const children = node.children.map(n => find_leaf(n, index))
const child = children.find(c => c != null)
return child || node
}
}
}
// Finds node declaring identifier with given index
export const find_declaration = (node, index) => {
if(node.type == 'let' || node.type == 'const') {
return node
}
if(node.type == 'function_decl' && node.index == index) {
return node
}
if(node.type == 'function_args') {
return node
}
if(node.type == 'import') {
return node
}
if(node.children == null) {
return null
}
const child = node.children.find(node => is_index_within_node(node, index))
if(child == null) {
return null
}
return find_declaration(child, index)
}
export const is_index_within_node = (node, index) =>
node.index <= index && node.index + node.length > index
export const is_child = (child, parent) => {
return parent.index <= child.index &&
(parent.index + parent.length) >= child.index + child.length
}
// TODO inconsistency. Sometimes we compare by identity, sometimes by this
// function
export const is_eq = (a, b) => {
return a.index == b.index && a.length == b.length
// Two different nodes can have the same index and length. Currently there
// is only one case: single-child do statement and its only child. So we
// add `type` to comparison. Better refactor it and add unique id to every
// node? Maybe also include `module` to id?
&& a.type == b.type
}
export const ancestry = (child, parent) => {
if(is_eq(parent, child)){
return []
} else {
if(parent.children == null){
return null
} else {
const c = parent.children.find(c => is_child(child, c))
if(c == null){
return null
} else {
return ancestry(child, c).concat([parent])
}
}
}
}
export const ancestry_inc = (child, parent) =>
[child, ...ancestry(child, parent)]
export const find_fn_by_location = (node, loc) => {
if(
node.index == loc.index && node.length == loc.length
// see comment for is_eq
&& node.type == 'function_expr'
) {
return node
} else if(node.children == null){
throw new Error('illegal state')
} else {
const c = node.children.find(c => is_child(loc, c))
if(c == null){
throw new Error('illegal state')
} else {
return find_fn_by_location(c, loc)
}
}
}
export const find_node = (node, pred) => {
if(pred(node)) {
return node
}
if(node.children == null) {
return null
}
return node
.children
.reduce(
(result, c) => result ?? find_node(c, pred),
null
)
}
// TODO result is ok, but value is rejected promise
export const find_error_origin_node = (node, is_root = true) => {
if(node.result == null) {
return null
}
if(node.type == 'function_expr' && !is_root) {
return null
}
if(node.result.is_error_origin) {
return node
}
if(node.children == null) {
return null
}
return node
.children
.reduce(
(result, c) => result ?? find_error_origin_node(c, false),
null
)
}
/* Maps tree nodes, discarding mapped children, so maps only node contents, not
* allowing to modify structure */
export const map_tree = (node, mapper) => {
const mapped = mapper(node)
if(node.children == null) {
return mapped
}
return {...mapped,
children: node.children.map(c => map_tree(c, mapper))
}
}

View File

@@ -1,973 +0,0 @@
import {update_children} from './parse_js.js'
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, eval_expand_calltree_node, get_after_if_path} from './eval.js'
export const version_number_symbol = Symbol('version_number')
export const is_versioned_object = o => o?.[version_number_symbol] != null
export const get_version_number = o => o[version_number_symbol]
export const pp_calltree = tree => ({
id: tree.id,
ok: tree.ok,
args: tree.args,
value: tree.value,
is_log: tree.is_log,
has_more_children: tree.has_more_children,
string: tree.code?.string,
children: tree.children && tree.children.map(pp_calltree)
})
export const current_cursor_position = state =>
state.cursor_position_by_file[state.current_module]
// When we open file for the first time, cursor set to the beginning
?? 0
export const set_cursor_position = (state, cursor_position) => (
{
...state,
cursor_position_by_file: {
...state.cursor_position_by_file, [state.current_module]: cursor_position
}
}
)
export const set_location = (state, location) => set_cursor_position(
{...state, current_module: location.module},
location.index
)
const is_stackoverflow = node =>
// Chrome
node.error?.message == 'Maximum call stack size exceeded'
||
// Firefox
node.error?.message == "too much recursion"
export const has_error = n =>
!n.ok
||
(
n.value instanceof globalThis.app_window.Promise
&&
n.value.status != null
&&
!n.value.status.ok
)
export const calltree_node_loc = node => node.toplevel
? {module: node.module}
: node.fn.__location
export const get_deferred_calls = state => state.calltree.children[1].children
export const root_calltree_node = state =>
// Returns calltree node for toplevel
// It is either toplevel for entrypoint module, or for module that throw
// error and prevent entrypoint module from executing.
// state.calltree.children[1] is deferred calls
state.calltree.children[0]
export const root_calltree_module = state =>
root_calltree_node(state).module
export const make_calltree = (root_calltree_node, deferred_calls) => ({
id: 'calltree',
children: [
root_calltree_node,
{id: 'deferred_calls', children: deferred_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]
export const get_calltree_node_by_loc = (state, index) => {
const nodes_of_module = state.calltree_node_by_loc.get(state.current_module)
if(nodes_of_module == null) {
return null
} else {
return nodes_of_module.get(index)
}
}
const get_selected_calltree_node_by_loc = (
state,
node,
module = state.current_module
) =>
state.selected_calltree_node_by_loc
?.[module]
?.[
state.parse_result.modules[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 set_node_by_loc = (node_by_loc, loc, node_id) => {
return {...node_by_loc,
[loc.module]: {
...node_by_loc?.[loc.module],
[loc.index ?? -1]: node_id
}
}
}
const set_selected_calltree_node_by_loc = (state, loc, node_id) => {
return {
...state,
selected_calltree_node_by_loc: set_node_by_loc(
state.selected_calltree_node_by_loc,
loc,
node_id,
)
}
}
export const set_active_calltree_node = (
state,
active_calltree_node,
current_calltree_node = state.current_calltree_node,
) => {
return {
...state,
active_calltree_node,
current_calltree_node,
}
}
export const add_frame = (
state,
active_calltree_node,
current_calltree_node = active_calltree_node,
) => {
let with_frame
let frame
frame = state.frames?.[active_calltree_node.id]
if(frame == null) {
frame = update_children(eval_frame(active_calltree_node, state.modules, state.rt_cxt))
const execution_paths = active_calltree_node.toplevel
? null
: get_execution_paths(frame)
const coloring = color(frame)
with_frame = {...state,
frames: {...state.frames,
[active_calltree_node.id]: {...frame, coloring, execution_paths}
}
}
} else {
with_frame = state
}
const loc = calltree_node_loc(active_calltree_node)
const with_colored_frames = {...with_frame,
colored_frames: set_node_by_loc(
with_frame.colored_frames,
loc,
active_calltree_node.id,
)
}
return set_active_calltree_node(
with_colored_frames,
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 expand_calltree_node = (state, node) => {
if(node.has_more_children) {
const next_node = eval_expand_calltree_node(
state.rt_cxt,
state.parse_result,
node
)
return {
state: {...state,
calltree: replace_calltree_node(state.calltree, node, next_node)
},
node: next_node
}
} else {
return {state, node}
}
}
const jump_calltree_node = (_state, _current_calltree_node, do_not_set_location = false) => {
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(state.calltree, current_calltree_node)
if(
current_calltree_node.toplevel
||
parent.id == 'deferred_calls'
) {
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)
let loc, callsite_node
if(show_body) {
loc = calltree_node_loc(next.active_calltree_node)
} else {
const frame = next.frames[active_calltree_node.id]
callsite_node = find_node(frame, n =>
(n.type == 'function_call' || n.type == 'new')
&&
n.result?.call?.id == current_calltree_node.id
)
loc = {
module: calltree_node_loc(active_calltree_node).module,
index: callsite_node.index
}
}
const with_location = do_not_set_location
? next
: next.current_calltree_node.toplevel
? {...next, current_module: loc.module}
// TODO: better jump not start of function (arguments), but start
// of body?
: set_location(next, loc)
const with_selected_calltree_node = set_selected_calltree_node_by_loc(
with_location,
calltree_node_loc(active_calltree_node),
with_location.active_calltree_node.id,
)
let value_explorer
if(next.current_calltree_node.toplevel) {
value_explorer = null
} else {
const _arguments = {
value:
show_body
? active_frame(with_selected_calltree_node)
// function args node
.children[0]
.result
.value
: current_calltree_node.args,
[version_number_symbol]: current_calltree_node.version_number,
}
value_explorer = {
index: loc.index,
result: {
ok: true,
is_calltree_node_explorer: true,
value: current_calltree_node.ok
? {
'*arguments*': _arguments,
'*return*': {
value: current_calltree_node.value,
[version_number_symbol]: current_calltree_node.last_version_number,
}
}
: {
'*arguments*': _arguments,
'*throws*': {
value: current_calltree_node.error,
[version_number_symbol]: current_calltree_node.last_version_number,
}
}
}
}
}
return {...with_selected_calltree_node,
value_explorer,
selection_state: show_body
? null
: {node: callsite_node}
}
}
const show_value_explorer = state => {
return jump_calltree_node(state, state.current_calltree_node, true)
}
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 =>
is_native_fn(node)
? (node.children != null && node.children.length != 0)
|| node.has_more_children
// Hosted node always can be expanded, even if has not children
// Toplevel cannot be expanded if has no children
: node.toplevel
? (node.children != null && node.children.length != 0)
: true
/*
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.id == 'calltree') {
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(state.calltree, current)
)
}
if(next_node?.id == 'deferred_calls') {
if(next_node.children == null) {
next_node = null
} else {
next_node = next_node.children[0]
}
}
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(state.calltree, current)
const child_index = parent.children.findIndex(c =>
c == current
)
const next_child = parent.children[child_index - 1]
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])
}
}
let next_node
if(next_child == null) {
next_node = parent.id == 'deferred_calls'
? last(root_calltree_node(state))
: parent
} else {
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) {
const [parent] = path_to_root(state.calltree, current)
if(parent.id == 'calltree' || parent.id == 'deferred_calls') {
return state
} else {
return jump_calltree_node(state, parent)
}
} 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
}
}
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 select_node = (state, id) => {
const node = find_node(state.calltree, n => n.id == id)
return jump_calltree_node(state, node)
}
const select_and_toggle_expanded = (state, id) => {
const node = find_node(state.calltree, n => n.id == id)
const nextstate = jump_calltree_node(state, node)
if(is_expandable(node)) {
return toggle_expanded(nextstate)
} else {
return nextstate
}
}
export const expand_path = (state, node) => {
if(state.calltree_node_is_expanded?.[node.id]) {
return state
}
return {
...state,
calltree_node_is_expanded: {
...state.calltree_node_is_expanded,
...Object.fromEntries(
path_to_root(state.calltree, 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 get_execution_paths = frame => {
/*
- depth-first search tree. if 'result == null', then stop. Do not descend
into function_expr
- look for 'if' and ternary
- Get executed branch (branch.result.ok != null). There may be no executed
branch if cond executed with error
- for 'if' statement we also add 'get_after_if_path(if_node.index)'
*/
const do_get = (node, next_node) => {
if(node.type == 'function_expr' || node.result == null) {
return []
}
let after_if, branch
if(node.type == 'if' || node.type == 'ternary') {
const [cond, ...branches] = node.children
branch = branches.find(b => b.result != null)
}
if(node.type == 'if' && next_node != null && next_node.result != null) {
after_if = get_after_if_path(node)
}
const paths = [branch?.index, after_if].filter(i => i != null)
const children = node.children ?? []
const child_paths = children
.map((c, i) => do_get(c, children[i + 1]))
.flat()
return [...paths, ...child_paths]
}
const [args, body] = frame.children
return new Set(do_get(body))
}
const find_execution_path = (node, index) => {
if(node.children == null) {
return []
}
const child = node.children.find(c =>
c.index <= index && c.index + c.length > index
)
if(child == null) {
return []
}
const prev_ifs = node
.children
.filter(c =>
c.index < child.index && c.type == 'if'
)
.map(c => get_after_if_path(c))
const child_path = find_execution_path(child, index)
// TODO other conditionals, like &&, ||, ??, ?., ?.[]
if(node.type == 'if' || node.type == 'ternary') {
const [cond, left, right] = node.children
if(child == left || child == right) {
return [...prev_ifs, child.index, ...child_path]
}
}
return [...prev_ifs, ...child_path]
}
export const find_call_node = (state, index) => {
const module = state.parse_result.modules[state.current_module]
if(module == null) {
// Module is not executed
return {node: null, path: null}
}
let node, path
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
path = null
} 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
path = find_execution_path(node, index)
}
return {node, path}
}
export const find_call = (state, index) => {
const {node, path} = find_call_node(state, index)
if(node == null) {
return state
}
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 selected_ct_node_id = get_selected_calltree_node_by_loc(state, node)
const execution_paths = selected_ct_node_id == null
? null
: state.frames[selected_ct_node_id].execution_paths
const ct_node_id = get_calltree_node_by_loc(state, node.index)
if(ct_node_id == null) {
return set_active_calltree_node(state, null)
}
const path_ct_node_id = [node.index, ...path]
.map(path_elem => {
const is_selected_node_hits_path =
selected_ct_node_id != null
&&
(execution_paths.has(path_elem) || node.index == path_elem)
if(is_selected_node_hits_path) {
return selected_ct_node_id
}
// If there is no node selected for this fn, or it did not hit the path
// when executed, try to find node calltree_node_by_loc
return get_calltree_node_by_loc(state, path_elem)
})
.findLast(node_id => node_id != null)
const ct_node = find_node(
state.calltree,
n => n.id == path_ct_node_id
)
if(ct_node == null) {
throw new Error('illegal state')
}
return add_frame(
expand_path(state, ct_node),
ct_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: set_location(state, {module: loc.module, index: code.body.index}),
effects: {type: 'set_focus'}
}
} 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.type == 'new')
&& n.result != null
&& n.result.call.id == state.current_calltree_node.id
)
node = find_node(code, n => is_eq(result_node, n))
}
return {
state: {
...set_location(state, {module: loc.module, index: node.index}),
selection_state: {
node,
},
value_explorer: {
index: node.index,
length: node.length,
result: result_node.result,
},
},
effects: {type: 'set_focus'}
}
}
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.type == 'new')
&& 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: {
...set_location(state, {module: loc.module, index: node.index}),
selection_state: {
node,
},
value_explorer: {
index: node.index,
length: node.length,
result,
},
},
effects: with_focus
? {type: 'set_focus'}
: null,
}
}
const select_error = state => {
const node = [
state.current_calltree_node,
state.active_calltree_node,
root_calltree_node(state),
// TODO deferred calls???
].find(n => has_error(n))
if(node == null) {
return {state, effects: [{type: 'set_status', args: ['no error found']}]}
}
const error_origin = find_node(node, n =>
has_error(n)
&& (
n.children == null
||
n.children.every(c =>
!has_error(c)
||
// Error in native fn
is_native_fn(c) && c.children == null
)
)
)
if(error_origin == null) {
throw new Error('illegal state: error origin not found')
}
const next = expand_path(jump_calltree_node(state, error_origin), error_origin)
const frame = active_frame(next)
const error_node = find_error_origin_node(frame)
return {
state: set_location(next, {
module: calltree_node_loc(error_origin).module,
index: error_node.index
}),
effects: {type: 'set_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(state.calltree, n =>
n.id == state.logs.logs[log_position].id
)
const {state: next, effects} = select_arguments(
expand_path(jump_calltree_node(state, node), node),
false,
)
if(effects != null) {
throw new Error('illegal state')
}
return {...next, logs: {...state.logs, log_position}}
}
export const calltree_commands = {
arrow_down,
arrow_up,
arrow_left,
arrow_right,
select_node,
select_and_toggle_expanded,
select_return_value,
select_arguments,
select_error,
navigate_logs_position,
navigate_logs_increment,
show_value_explorer,
}

View File

@@ -1,144 +0,0 @@
// TODO time-travel for canvas ImageData
import {abort_replay} from './runtime/record_io.js'
import {set_record_call} from './runtime/runtime.js'
import {is_expandable} from './calltree.js'
const context_reset = globalThis?.CanvasRenderingContext2D?.prototype?.reset
function reset(context) {
if(context_reset != null) {
context_reset.call(context)
} else {
// For older browsers, `reset` may be not available
// changing width does the same as `reset`
// see https://stackoverflow.com/a/45871243/795038
context.canvas.width = context.canvas.width + 0
}
}
function canvas_reset(canvas_ops) {
for(let context of canvas_ops.contexts) {
reset(context)
}
}
export function apply_canvas_patches(window) {
const proto = window?.CanvasRenderingContext2D?.prototype
if(proto == null) {
return
}
const props = Object.getOwnPropertyDescriptors(proto)
Object.entries(props).forEach(([name, p]) => {
if(p.value != null) {
if(typeof(p.value) != 'function') {
// At the moment this was written, all canvas values were functions
return
}
const method = p.value
proto[name] = {
// declare function like this so it has `name` property set
[name]() {
const cxt = window.__cxt
set_record_call(cxt)
/*
abort replay, because otherwise animated_fractal_tree would replay
instantly (because setTimeout is in io_trace)
*/
if(!cxt.io_trace_is_recording && !cxt.is_recording_deferred_calls) {
abort_replay(cxt)
}
const version_number = ++cxt.version_counter
try {
return method.apply(this, arguments)
} finally {
cxt.canvas_ops.contexts.add(this)
cxt.canvas_ops.ops.push({
canvas_context: this,
method,
version_number,
args: arguments,
})
}
}
}[name]
}
if(p.set != null) {
const set_op = p.set
Object.defineProperty(proto, name, {
set(prop_value) {
const cxt = window.__cxt
set_record_call(cxt)
if(!cxt.io_trace_is_recording && !cxt.is_recording_deferred_calls) {
abort_replay(cxt)
}
const version_number = ++cxt.version_counter
try {
set_op.call(this, prop_value)
} finally {
cxt.canvas_ops.contexts.add(this)
cxt.canvas_ops.ops.push({
canvas_context: this,
version_number,
set_op,
prop_value,
})
}
}
})
}
})
}
function replay_op(op) {
if(op.method != null) {
op.method.apply(op.canvas_context, op.args)
} else if(op.set_op != null) {
op.set_op.call(op.canvas_context, op.prop_value)
} else {
throw new Error('illegal op')
}
}
export function redraw_canvas(state, is_replay_all_canvs_ops) {
if(state.calltree == null) {
// code is invalid or not executed yet
return
}
const cxt = state.rt_cxt
if(cxt.canvas_ops.ops == null) {
return
}
canvas_reset(cxt.canvas_ops)
if(is_replay_all_canvs_ops) {
for(let op of cxt.canvas_ops.ops) {
replay_op(op)
}
} else {
const current = state.current_calltree_node
// replay all ops up to current_calltree_node, including
const version_number = state.current_calltree_node.last_version_number
for(let op of cxt.canvas_ops.ops) {
if(op.version_number > version_number) {
break
}
replay_op(op)
}
}
}

View File

@@ -1,938 +0,0 @@
import {map_object, map_find, filter_object, collect_nodes_with_parents, uniq,
set_is_eq} from './utils.js'
import {
is_eq, is_child, ancestry, ancestry_inc, map_tree,
find_leaf, find_fn_by_location, find_node, find_error_origin_node,
collect_external_imports, collect_destructuring_identifiers
} from './ast_utils.js'
import {load_modules} from './parse_js.js'
import {eval_modules} from './eval.js'
import {
root_calltree_node, root_calltree_module, make_calltree,
get_deferred_calls,
calltree_commands,
add_frame, calltree_node_loc, expand_path,
initial_calltree_node, default_expand_path, toggle_expanded, active_frame,
find_call, set_active_calltree_node,
set_cursor_position, current_cursor_position, set_location,
is_native_fn,
} from './calltree.js'
// external
import {with_version_number} from './runtime/runtime.js'
const collect_logs = (logs, call) => {
const id_to_log = new Map(
collect_nodes_with_parents(call, n => n.is_log)
.map(({parent, node}) => (
[
node.id,
{
id: node.id,
version_number: node.version_number,
toplevel: parent.toplevel,
module: parent.toplevel
? parent.module
: parent.fn.__location.module,
parent_name: parent.fn?.name,
args: node.args,
log_fn_name: node.fn.name,
}
]
))
)
return logs.map(l => id_to_log.get(l.id))
}
export const with_version_number_of_log = (state, log_item, action) =>
with_version_number(state.rt_cxt, log_item.version_number, action)
const apply_eval_result = (state, eval_result) => {
// TODO what if console.log called from native fn (like Array::map)?
// Currently it is not recorded. Maybe we should monkey patch `console`?
return {
...state,
calltree: make_calltree(eval_result.calltree, null),
calltree_node_by_loc: eval_result.calltree_node_by_loc,
// TODO copy rt_cxt?
rt_cxt: eval_result.rt_cxt,
logs: {
logs: collect_logs(eval_result.logs, eval_result.calltree),
log_position: null
},
modules: eval_result.modules,
io_trace:
(eval_result.io_trace == null || eval_result.io_trace.length == 0)
// If new trace is empty, reuse previous trace
? state.io_trace
: eval_result.io_trace
}
}
const run_code = (s, globals) => {
const is_globals_eq = s.globals == null
? globals == null
: set_is_eq(s.globals, globals)
// If globals change, then errors for using undeclared identifiers may be
// no longer valid. Do not use cache
const parse_cache = is_globals_eq ? s.parse_result.cache : {}
const loader = module => s.files[module]
const parse_result = load_modules(s.entrypoint, loader, parse_cache, globals)
const state = {
...s,
globals,
parse_result,
calltree: null,
modules: null,
// Shows that calltree is brand new and requires entire rerender
calltree_changed_token: {},
rt_cxt: null,
logs: null,
current_calltree_node: null,
active_calltree_node: null,
calltree_node_is_expanded: null,
frames: null,
colored_frames: null,
calltree_node_by_loc: null,
selected_calltree_node_by_loc: null,
selection_state: null,
loading_external_imports_state: null,
value_explorer: null,
}
if(!state.parse_result.ok) {
return state
}
const external_imports = uniq(
collect_external_imports(state.parse_result.modules)
.map(i => i.node.full_import_path)
)
if(external_imports.length != 0) {
// Trigger loading of external modules
return {...state,
loading_external_imports_state: {
external_imports,
}
}
} else {
// Modules were loaded and cached, proceed
return external_imports_loaded(state, state)
}
}
const external_imports_loaded = (
s,
prev_state,
external_imports,
) => {
if(
s.loading_external_imports_state
!=
prev_state.loading_external_imports_state
) {
// code was modified after loading started, discard
return s
}
const state = {
...s,
loading_external_imports_state: null
}
if(external_imports != null) {
const errors = new Set(
Object
.entries(external_imports)
.filter(([url, result]) => !result.ok)
.map(([url, result]) => url)
)
if(errors.size != 0) {
const problems = collect_external_imports(state.parse_result.modules)
.filter(({node}) => errors.has(node.full_import_path))
.map(({node, module_name}) => ({
index: node.index,
message: external_imports[node.full_import_path].error.message,
module: module_name,
}))
return {...state,
parse_result: {
ok: false,
cache: state.parse_result.cache,
problems,
}
}
}
}
// TODO if module not imported, then do not run code on edit at all
const result = eval_modules(
state.parse_result,
external_imports,
state.on_deferred_call,
state.calltree_changed_token,
state.io_trace,
state.storage,
)
if(result.then != null) {
return {...state,
eval_modules_state: { promise: result }
}
} else {
return eval_modules_finished(state, state, result)
}
}
const eval_modules_finished = (state, prev_state, result) => {
if(state.calltree_changed_token != prev_state.calltree_changed_token) {
// code was modified after prev vesion of code was executed, discard
return state
}
if(result.rt_cxt.io_trace_is_replay_aborted) {
// execution was discarded, return state to execute `run_code` without io_trace
return clear_io_trace({...state, rt_cxt: result.rt_cxt})
}
const next = find_call(
apply_eval_result(state, result),
current_cursor_position(state)
)
let result_state
if(next.active_calltree_node == null) {
const {node, state: next2} = initial_calltree_node(next)
result_state = set_active_calltree_node(next2, null, node)
} else {
result_state = default_expand_path(
expand_path(
next,
next.active_calltree_node
)
)
}
const eval_state_clear = result_state.eval_modules_state == null
? result_state
: {...result_state, eval_modules_state: null}
return do_move_cursor(
eval_state_clear,
current_cursor_position(eval_state_clear)
)
}
const input = (state, code, index) => {
const files = {...state.files, [state.current_module]: code}
const with_files = {
...state,
files,
parse_result: state.parse_result == null
? null
: {
...state.parse_result,
cache: filter_object(state.parse_result.cache, module =>
module != state.current_module
)
}
}
const next = set_cursor_position(with_files, index)
const effect_save = {
type: 'write',
args: [
next.current_module,
next.files[next.current_module],
]
}
return {state: next, effects: [effect_save]}
}
const can_evaluate_node = (parent, node) => {
const anc = ancestry(node, parent)
if(anc == null){
return {ok: false, message: 'out of scope'}
}
const intermediate_fn = anc.find(n =>
!is_eq(n, parent) && !is_eq(n, node) && n.type == 'function_expr'
)
if(intermediate_fn != null){
// TODO check if identifier is defined in current scope, and eval
return {ok: false, message: 'code was not reached during program execution'}
}
return {ok: true}
}
const validate_index_action = state => {
if(!state.parse_result.ok){
return {state, effects: {type: 'set_status', args: ['invalid syntax']}}
}
if(
state.loading_external_imports_state != null
||
state.eval_modules_state != null
) {
return {state, effects: {type: 'set_status', args: ['loading']}}
}
if(
state.active_calltree_node == null
||
calltree_node_loc(state.active_calltree_node).module != state.current_module
) {
return {
state,
effects: {
type: 'set_status',
args: ['code was not reached during program execution']
}
}
}
}
const get_step_into_node = (ast, frame, index) => {
// TODO step into from toplevel (must be fixed by frame follows cursor)
const node = find_leaf(ast, index)
// Find parent node with function call
const call = ancestry_inc(node, ast).find(n => n.type == 'function_call')
if(call == null){
return {ok: false, message: 'no function call to step into'}
}
const can_eval = can_evaluate_node(frame, call)
if(!can_eval.ok){
return {ok: false, message: can_eval.message}
}
const callnode = find_node(frame, n => is_eq(n, call))
if(callnode.result == null) {
return {ok: false, message: 'call was not reached during program execution'}
} else {
return {ok: true, calltree_node: callnode.result.call}
}
}
const step_into = (state, index) => {
const validate_result = validate_index_action(state)
if(validate_result != null) {
return validate_result
}
const {ok, message, calltree_node} = get_step_into_node(
state.parse_result.modules[state.current_module],
active_frame(state),
index
)
if(is_native_fn(calltree_node)) {
return {
state,
effects: {
type: 'set_status',
args: ['Cannot step into: function is either builtin or from external lib']
}
}
}
if(!ok){
return {state, effects: {type: 'set_status', args: [message]}}
} else {
const expanded = {
...state, calltree_node_is_expanded: {
...state.calltree_node_is_expanded, [calltree_node.id]: true
}
}
return toggle_expanded(
{...expanded, current_calltree_node: calltree_node},
true
)
}
}
const get_next_selection_state = (selection_state, frame, is_expand, index) => {
if(selection_state != null && selection_state.index == index){
// Expanding/collapsing selection
let next_node
const effective_is_expand = selection_state.initial_is_expand == is_expand
if(effective_is_expand){
if(is_eq(selection_state.node, frame)) {
next_node = selection_state.node
} else {
next_node = ancestry(selection_state.node, frame).find(n => !n.not_evaluatable)
if(next_node.is_statement) {
next_node = selection_state.node
}
}
} else {
// collapse
if(selection_state.node.children != null){
const leaf = find_leaf(selection_state.node, index)
next_node = ancestry_inc(leaf, selection_state.node)
.findLast(n => !n.not_evaluatable && n != selection_state.node)
} else {
// no children, cannot collapse
next_node = selection_state.node
}
}
return {
ok: true,
initial_is_expand: selection_state.initial_is_expand,
node: next_node,
index,
}
} else {
// Creating new selection
const leaf = find_leaf(frame, index);
const a = ancestry_inc(leaf, frame);
const node = a.find(n => !n.not_evaluatable);
if(node.is_statement) {
return {
ok: false,
message: 'can only evaluate expression, not statement',
}
}
return {
ok: true,
index,
node,
initial_is_expand: is_expand,
}
}
}
export const selection = (selection_state, frame, is_expand, index) => {
const leaf = find_leaf(frame, index)
if(leaf == null) {
return {
selection_state: {
ok: false,
message: 'out of scope',
}
}
}
const next_selection_state = get_next_selection_state(selection_state, frame, is_expand, index)
if(!next_selection_state.ok) {
return {selection_state: next_selection_state}
}
const {ok, message} = can_evaluate_node(frame, next_selection_state.node)
if(ok){
const node = find_node(frame, n => is_eq(n, next_selection_state.node))
if(node.result == null) {
return {
selection_state: {
...next_selection_state,
ok: false,
message: 'expression was not reached during program execution',
}
}
} else {
let result
if(node.result.ok) {
result = node.result
} else {
const error_node = find_error_origin_node(node)
result = error_node.result
}
return {
selection_state: {...next_selection_state, ok: true},
result
}
}
} else {
return {
selection_state: {...next_selection_state, ok: false, message}
}
}
}
const eval_selection = (state, index, is_expand) => {
const validate_result = validate_index_action(state)
if(validate_result != null) {
return validate_result
}
const {selection_state, result} = selection(
state.selection_state,
active_frame(state),
is_expand,
index
)
const nextstate = {...state,
selection_state,
value_explorer: selection_state.ok
? {
node: selection_state.node,
index: selection_state.node.index,
length: selection_state.node.length,
result,
}
: null
}
if(!selection_state.ok) {
return {state: nextstate, effects: {type: 'set_status', args: [selection_state.message]}}
}
return {state: nextstate}
}
const change_current_module = (state, current_module) => {
if(state.files[current_module] == null) {
return {
state,
effects: {type: 'set_status', args: ['File not found']}
}
} else {
return {...state, current_module}
}
}
const change_entrypoint = (state, entrypoint, current_module = entrypoint) => {
return {...state,
entrypoint,
current_module,
}
}
const change_html_file = (state, html_file) => {
return {...state, html_file}
}
const goto_location = (state, loc) => {
return {
state: move_cursor(set_location(state, loc), loc.index),
effects: {type: 'set_focus'},
}
}
const goto_definition = (state, index) => {
if(!state.parse_result.ok){
return {state, effects: {type: 'set_status', args: ['unresolved syntax errors']}}
} else {
const module = state.parse_result.modules[state.current_module]
const node = find_leaf(module, index)
if(node == null || node.type != 'identifier') {
return {state, effects: {type: 'set_status', args: ['not an identifier']}}
} else {
const d = node.definition
if(d == 'global') {
return {state, effects: {type: 'set_status', args: ['global variable']}}
} else if (d == 'self') {
// place where identifier is declared, nothing to do
return {state}
} else {
let loc
if(d.module != null) {
const exp = map_find(state.parse_result.modules[d.module].children, n => {
if(n.type != 'export') {
return null
}
if(n.is_default && d.is_default) {
return n.children[0]
} else if(!n.is_default && !d.is_default) {
const ids = n.binding.children.flatMap(c =>
collect_destructuring_identifiers(c.name_node)
)
return ids.find(i => i.value == node.value)
}
})
loc = {module: d.module, index: exp.index}
} else {
loc = {module: state.current_module, index: d.index}
}
return goto_location(state, loc)
}
}
}
}
const goto_problem = (state, p) => {
return {
state: set_location(state, p),
effects: {type: 'set_focus'}
}
}
// TODO remove?
// TODO: to every child, add displayed_children property
/*
const filter_calltree = (calltree, pred) => {
const do_filter_calltree = calltree => {
const children = calltree.children && calltree.children
.map(c => do_filter_calltree(c))
.flat()
if(pred(calltree)) {
return [{...calltree, children}]
} else {
return children
}
}
const result = do_filter_calltree(calltree)
if(result.length == 1 && result[0].toplevel) {
return result[0]
} else {
return {...calltree, children: result}
}
}
*/
const get_stmt_value_explorer = (state, stmt) => {
if(stmt.result == null) {
// statement was not evaluated
return null
}
let result
if(stmt.result.ok) {
if(stmt.type == 'return') {
if(stmt.expr == null) {
// add fake version number
result = {ok: true, value: undefined, version_number: 0}
} else {
result = stmt.children[0].result
}
} else if(['let', 'const', 'assignment'].includes(stmt.type)) {
if(stmt.children.find(c => c.type == 'assignment_pair') != null) {
if(stmt.children.length != 1) {
// Multiple assignments, not clear what value to show in value
// explorer, show nothing
return null
}
// get result of first assignment
result = stmt.children[0].result
} else {
const identifiers = stmt
.children
.flatMap(
collect_destructuring_identifiers
)
.filter(id => id.result != null)
.map(id => [id.value, id.result.value])
let value
if(
stmt.children.length == 1
&&
(
stmt.children[0].type == 'identifier'
||
stmt.children[0].type == 'decl_pair'
&&
stmt.children[0].name_node.type == 'identifier'
)
) {
// Just a single declaration
if(identifiers.length != 1) {
throw new Error('illegal state')
}
value = identifiers[0][1]
} else {
value = Object.fromEntries(identifiers)
}
// TODO different identifiers may have different version_number,
// because there may be function calls and assignments in between fix
// it
const version_number = stmt.children[0].result.version_number
return {
index: stmt.index,
length: stmt.length,
result: {ok: true, value, version_number},
}
}
} else if(stmt.type == 'if'){
return null
} else if(stmt.type == 'import'){
result = {
ok: true,
value: state.modules[stmt.full_import_path],
// For imports, we show version for the moment of module toplevel
// starts execution
version_number: state.active_calltree_node.version_number,
}
} else if (stmt.type == 'export') {
return get_stmt_value_explorer(state, stmt.children[0])
} else {
result = stmt.result
}
} else {
result = find_error_origin_node(stmt).result
}
return {index: stmt.index, length: stmt.length, result}
}
const get_value_explorer = (state, index) => {
if(
state.active_calltree_node == null
||
(
state.current_module
!=
calltree_node_loc(state.active_calltree_node).module
)
) {
return null
}
const frame = active_frame(state)
if(
true
// not toplevel, function expr
&& frame.type == 'function_expr'
&& index >= frame.children[0].index
&& index < frame.children[0].index + frame.children[0].length
) {
if(frame.children[0].children.length == 0) {
// Zero args
return null
} else {
// cursor in args, show args
return {
index: frame.children[0].index,
length: frame.children[0].length,
result: frame.children[0].result,
}
}
}
if(frame.type == 'function_expr' && frame.body.type != 'do') {
const result = frame.children[1].result
if(result == null) {
// Error in arguments, body not evaluated
return null
}
return {
index: frame.children[1].index,
length: frame.children[1].length,
result: result.ok
? result
: find_error_origin_node(frame.children[1]).result
}
}
const leaf = find_leaf(frame, index)
const adjusted_leaf = (
// We are in the whitespace at the beginning or at the end of the file
leaf == null
||
// Empty body or cursor between statements
leaf.type == 'do' && index > frame.index
)
// Try find statement one symbol before, in case we are typing at the end
// of current statement
? find_leaf(frame, index - 1)
: leaf
if(
adjusted_leaf == null
||
adjusted_leaf.type == 'do'
||
/* between body and args*/
is_eq(frame, adjusted_leaf)
) {
return null
}
const anc = ancestry_inc(adjusted_leaf, frame)
const intermediate_fn = anc.find(n =>
!is_eq(n, frame) && !is_eq(n, adjusted_leaf) && n.type == 'function_expr'
)
if(intermediate_fn != null) {
// TODO maybe cut `anc` from frame to intermediate fn, so we do not look
// inside intermediate fn. But it should be fixed by frame follows cursor
return null
}
// Find inner do
const do_index = anc.findIndex(n => n.type == 'do')
const do_node = anc[do_index]
const stmt = anc[do_index - 1]
return get_stmt_value_explorer(state, stmt)
}
const do_move_cursor = (state, index) => {
// TODO: if value explorer is null, show current fn return value and args?
const value_explorer = get_value_explorer(state, index)
if(
value_explorer != null
&& value_explorer.result.ok
&& value_explorer.result.version_number == null
) {
console.error('no version_number found', value_explorer)
throw new Error('illegal state')
}
return { ...state, value_explorer}
}
const move_cursor = (s, index) => {
const with_cursor = set_cursor_position(s, index)
if(!s.parse_result.ok){
return {state: with_cursor}
}
if(s.loading_external_imports_state != null || s.eval_modules_state != null) {
// Code will be executed when imports will load, do not do it right now
return {state: with_cursor}
}
// Remove selection on move cursor
const state_sel_removed = {...with_cursor, selection_state: null}
const state = find_call(state_sel_removed, index)
const validate_result = validate_index_action(state)
if(validate_result != null) {
return { ...state, value_explorer: null }
}
return do_move_cursor(state, index)
}
const on_deferred_call = (state, call, calltree_changed_token, logs) => {
if(state.calltree_changed_token != calltree_changed_token) {
return state
}
return {...state,
calltree: make_calltree(
root_calltree_node(state),
[...(get_deferred_calls(state) ?? []), call],
),
logs: {
...state.logs,
logs: state.logs.logs.concat(collect_logs(logs, call))
},
}
}
const clear_io_trace = state => {
return {...state, io_trace: null}
}
const load_files = (state, dir) => {
const collect_files = dir => dir.kind == 'file'
? [dir]
: dir.children.map(collect_files).flat()
const files = Object.fromEntries(
collect_files(dir).map(f => [f.path, f.contents])
)
return {
...state,
project_dir: dir,
files: {...files, '': state.files['']},
}
}
const apply_entrypoint_settings = (state, entrypoint_settings) => {
const blank_if_not_exists = key =>
state.files[entrypoint_settings[key]] == null
? ''
: entrypoint_settings[key]
const entrypoint = blank_if_not_exists('entrypoint')
const current_module = blank_if_not_exists('current_module')
const html_file = blank_if_not_exists('html_file')
return {
...state,
entrypoint,
current_module,
html_file,
}
}
const load_dir = (state, dir, has_file_system_access, entrypoint_settings) => {
// Clear parse cache and rerun code
const with_dir = load_files(state, dir)
return {
...(
entrypoint_settings == null
? with_dir
: apply_entrypoint_settings(with_dir, entrypoint_settings)
),
has_file_system_access,
// remove cache. We have to clear cache because imports of modules that are
// not available because project_dir is not available have errors and the
// errors are cached
parse_result: {...state.parse_result, cache: {}},
}
}
const create_file = (state, dir, current_module) => {
return {...load_dir(state, dir, true), current_module}
}
const open_app_window = state => ({...state, storage: new Map()})
const get_initial_state = (state, entrypoint_settings, cursor_pos = 0) => {
const with_files = state.project_dir == null
? state
: load_files(state, state.project_dir)
const with_settings = apply_entrypoint_settings(with_files, entrypoint_settings)
return {
...with_settings,
storage: new Map(),
cursor_position_by_file: {[with_settings.current_module]: cursor_pos},
}
}
export const COMMANDS = {
get_initial_state,
input,
run_code,
open_app_window,
load_dir,
create_file,
step_into,
change_current_module,
change_entrypoint,
change_html_file,
goto_location,
goto_definition,
goto_problem,
move_cursor,
eval_selection,
external_imports_loaded,
eval_modules_finished,
on_deferred_call,
clear_io_trace,
calltree: calltree_commands,
}

View File

@@ -1,255 +0,0 @@
const collect_function_exprs = new Function('root', `
const result = []
function do_collect(n) {
if(n.type == 'function_expr') {
result.push(n)
return
}
if(n.children == null) {
return
}
for(let c of n.children) {
do_collect(c)
}
}
do_collect(root)
return result
`)
const is_result_eq = (a,b) => a.result == null
? b.result == null
: b.result != null
&& a.result.ok == b.result.ok
&& !(!a.result.is_error_origin) == !(!b.result.is_error_origin)
const node_to_color = node => ({
index: node.index,
length: node.length,
result: node.result == null
? null
: node.type == 'function_expr'
? null
: node.result.ok
? {ok: true}
: {ok: false, is_error_origin: !(!node.result.is_error_origin)}
})
const is_short_circuit = node =>
node.type == 'binary' || node.type == 'ternary'
const color_children = (node, is_root) => {
const coloring = node.children.map(n => do_color(n)).reduce(
(coloring, [range, ...rest]) => {
if(coloring.length == 0) {
return [range, ...rest]
} else {
const prev_range = coloring[coloring.length - 1]
if(is_result_eq(prev_range, range)) {
// Merge ranges
return [
...coloring.slice(0, coloring.length - 1),
{
index: prev_range.index,
length: range.index - prev_range.index + range.length,
result: range.result == null ? null : {ok: range.result.ok}
},
...rest
]
} else if(!is_short_circuit(node) && prev_range.result == null && range.result?.ok){
// Expand range back to the end of prev range
const index = prev_range.index + prev_range.length
return [
...coloring,
{...range,
index,
length: range.index - index + range.length,
},
...rest,
]
} else if(!is_short_circuit(node) && prev_range.result?.ok && range.result == null) {
// Expand prev_range until beginning of range
const index = prev_range.index + prev_range.length
return [
...coloring.slice(0, coloring.length - 1),
{...prev_range,
length: range.index - prev_range.index
},
range,
...rest,
]
} else {
// Append range
return [
...coloring,
range,
...rest,
]
}
}
},
[]
)
if(
node.result == null || node.result?.ok
&&
// All colors the same
coloring.reduce(
(result, c) => result && is_result_eq(coloring[0], c),
true
)
) {
if(is_result_eq(node, coloring[0])) {
if(is_root && node.type == 'function_expr') {
// Override null result for function expr
return [{...node_to_color(node), result: {ok: node.result.ok}}]
} else {
return [node_to_color(node)]
}
} else {
const node_color = node_to_color(node)
const last = coloring[coloring.length - 1]
const index = coloring[0].index + coloring[0].length
return [
{
...node_color,
length: coloring[0].index - node_color.index,
},
...coloring,
{
...node_color,
index,
length: node.index + node.length - index,
},
]
}
}
if(coloring.length == 0) {
throw new Error('illegal state')
}
// if first child is ok, then expand it to the beginning of parent
const first = coloring[0]
const adj_left = is_result_eq(first, node) && node.result?.ok
? [
{...first,
index: node.index,
length: first.length + first.index - node.index
},
...coloring.slice(1),
]
: coloring
// if last child is ok, then expand it to the end of parent
const last = adj_left[adj_left.length - 1]
const adj_right = is_result_eq(last, node) && node.result?.ok
? [
...adj_left.slice(0, adj_left.length - 1),
{...last,
index: last.index,
length: node.index + node.length - last.index,
},
]
: adj_left
return adj_right
}
const do_color = (node, is_root = false) => {
if(node.type == 'function_expr' && !is_root) {
return [{...node_to_color(node), result: null}]
}
if(
false
|| node.children == null
|| node.children.length == 0
) {
return [node_to_color(node)]
}
if(node.result?.is_error_origin) {
const color = node_to_color(node)
const exprs = collect_function_exprs(node)
if(exprs.length == 0) {
return [color]
}
// Color node in red, but make holes for function exprs
return exprs
.map((e, i) => {
let prev_index
if(i == 0) {
prev_index = node.index
} else {
const prev_expr = exprs[i - 1]
prev_index = prev_expr.index + prev_expr.length
}
return {
index: prev_index,
length: e.index - prev_index,
result: color.result
}
})
.concat([{
index: exprs.at(-1).index + exprs.at(-1).length,
length: node.index + node.length
- (exprs.at(-1).index + exprs.at(-1).length),
result: color.result,
}])
}
const result = color_children(node, is_root)
return node.result != null && !node.result.ok
? result.map(c => c.result == null
? {...c, result: {ok: false, is_error_origin: false}}
: c
)
: result
}
export const color = frame => {
const coloring = do_color(frame, true)
.filter(c =>
// Previously we colored nodes that were not reach to grey color, now we
// just skip them
c.result != null
&&
// Parts that were not error origins
(c.result.ok || c.result.is_error_origin)
)
// Sanity-check result
const {ok} = coloring.reduce(
({ok, prev}, c) => {
if(!ok) {
return {ok}
}
if(prev == null) {
return {ok, prev: c}
} else {
// Check that prev is before next
// TODO check that next is right after prev, ie change > to ==
if(prev.index + prev.length > c.index) {
return {ok: false}
} else {
return {ok: true, prev: c}
}
}
},
{ok: true, prev: null}
)
if(!ok) {
throw new Error('illegal state')
}
return coloring
}
export const color_file = (state, file) =>
Object
.values(state.colored_frames?.[file] ?? {})
.filter(node_id => node_id != null)
.map(node_id => state.frames[node_id].coloring)
.flat()

View File

@@ -1,242 +0,0 @@
import {exec} from '../index.js'
import {el, scrollIntoViewIfNeeded, value_to_dom_el, join} from './domutils.js'
import {stringify_for_header} from '../value_explorer_utils.js'
import {find_node} from '../ast_utils.js'
import {with_version_number} from '../runtime/runtime.js'
import {is_expandable, root_calltree_node, get_deferred_calls, has_error}
from '../calltree.js'
export class CallTree {
constructor(ui, container) {
this.ui = ui
this.container = container
this.container.addEventListener('keydown', (e) => {
if(e.key == 'Escape') {
this.ui.editor.focus()
}
if(e.key == 'F1') {
this.ui.editor.focus_value_explorer(this.container)
}
if(e.key == 'F2') {
this.ui.editor.focus()
}
if(e.key == 'a') {
exec('calltree.select_arguments')
}
if(e.key == 'e') {
exec('calltree.select_error')
}
if(e.key == 'r' || e.key == 'Enter') {
exec('calltree.select_return_value')
}
if(e.key == 'ArrowDown' || e.key == 'j'){
// Do not scroll
e.preventDefault()
exec('calltree.arrow_down')
}
if(e.key == 'ArrowUp' || e.key == 'k'){
// Do not scroll
e.preventDefault()
exec('calltree.arrow_up')
}
if(e.key == 'ArrowLeft' || e.key == 'h'){
// Do not scroll
e.preventDefault()
exec('calltree.arrow_left')
}
if(e.key == 'ArrowRight' || e.key == 'l'){
// Do not scroll
e.preventDefault()
exec('calltree.arrow_right')
}
})
}
on_click_node(ev, id) {
if(ev.target.classList.contains('expand_icon')) {
exec('calltree.select_and_toggle_expanded', id)
} else {
exec('calltree.select_node', id)
}
}
clear_calltree(){
this.container.innerHTML = ''
this.node_to_el = new Map()
this.state = null
}
render_node(n){
const is_expanded = this.state.calltree_node_is_expanded[n.id]
const result = el('div', 'callnode',
el('div', {
'class': 'call_el',
click: e => this.on_click_node(e, n.id),
},
!is_expandable(n)
? el('span', 'expand_icon_placeholder', '\xa0')
: el('span', 'expand_icon', is_expanded ? '▼' : '▶'),
n.toplevel
? el('span', '',
el('i', '',
'toplevel: ' + (n.module == '' ? '*scratch*' : n.module),
),
n.ok ? '' : el('span', 'call_header error', '\xa0', stringify_for_header(n.error)),
)
: el('span',
'call_header '
+ (has_error(n) ? 'error' : '')
+ (n.fn.__location == null ? ' native' : '')
,
// TODO show `this` argument
(n.is_new ? 'new ' : ''),
n.fn.name,
'(' ,
...join(
// for arguments, use n.version_number - last version before call
with_version_number(this.state.rt_cxt, n.version_number, () =>
n.args.map(a => value_to_dom_el(a))
)
),
')' ,
// TODO: show error message only where it was thrown, not every frame?
': ',
// for return value, use n.last_version_number - last version that was
// created during call
with_version_number(this.state.rt_cxt, n.last_version_number, () =>
n.ok
? value_to_dom_el(n.value)
: stringify_for_header(n.error)
)
),
),
(n.children == null || !is_expanded)
? null
: n.children.map(c => this.render_node(c))
)
this.node_to_el.set(n.id, result)
result.is_expanded = is_expanded
return result
}
render_active(node, is_active) {
const dom = this.node_to_el.get(node.id).getElementsByClassName('call_el')[0]
if(is_active) {
dom.classList.add('active')
} else {
dom.classList.remove('active')
}
}
render_select_node(prev, state) {
if(prev != null) {
this.render_active(prev.current_calltree_node, false)
}
this.state = state
this.render_active(this.state.current_calltree_node, true)
if(prev?.current_calltree_node != state.current_calltree_node) {
// prevent scroll on adding deferred call
scrollIntoViewIfNeeded(
this.container,
this.node_to_el.get(this.state.current_calltree_node.id).getElementsByClassName('call_el')[0]
)
}
}
render_expand_node(prev_state, state) {
this.state = state
this.do_render_expand_node(
prev_state.calltree_node_is_expanded,
state.calltree_node_is_expanded,
root_calltree_node(prev_state),
root_calltree_node(state),
)
const prev_deferred_calls = get_deferred_calls(prev_state)
const deferred_calls = get_deferred_calls(state)
if(prev_deferred_calls != null) {
// Expand already existing deferred calls
for(let i = 0; i < prev_deferred_calls.length; i++) {
this.do_render_expand_node(
prev_state.calltree_node_is_expanded,
state.calltree_node_is_expanded,
prev_deferred_calls[i],
deferred_calls[i],
)
}
// Add new deferred calls
for(let i = prev_deferred_calls.length; i < deferred_calls.length; i++) {
this.deferred_calls_root.appendChild(
this.render_node(deferred_calls[i])
)
}
}
this.render_select_node(prev_state, state)
}
do_render_expand_node(prev_exp, next_exp, prev_node, next_node) {
if(prev_node.id != next_node.id) {
throw new Error()
}
if(!!prev_exp[prev_node.id] != !!next_exp[next_node.id]) {
const prev_dom_node = this.node_to_el.get(prev_node.id)
const next = this.render_node(next_node)
prev_dom_node.parentNode.replaceChild(next, prev_dom_node)
} else {
if(prev_node.children == null) {
return
}
for(let i = 0; i < prev_node.children.length; i++) {
this.do_render_expand_node(
prev_exp,
next_exp,
prev_node.children[i],
next_node.children[i],
)
}
}
}
// TODO on hover highlight line where function defined
// TODO hover ?
render_calltree(state){
this.clear_calltree()
this.state = state
const root = root_calltree_node(this.state)
this.container.appendChild(this.render_node(root))
this.render_select_node(null, state)
}
render_deferred_calls(state) {
this.state = state
this.container.appendChild(
el('div', 'callnode',
el('div', 'call_el',
el('i', '', 'deferred calls'),
this.deferred_calls_root = el('div', 'callnode',
get_deferred_calls(state).map(call => this.render_node(call))
)
)
)
)
}
}

View File

@@ -1,118 +0,0 @@
import {exec} from '../index.js'
import {stringify_for_header} from '../value_explorer_utils.js'
export function el(tag, className, ...children){
const result = document.createElement(tag)
if(typeof(className) == 'string'){
result.setAttribute('class', className)
} else {
const attrs = className
for(let attrName in attrs){
const value = attrs[attrName]
if(['change','click'].includes(attrName)){
result.addEventListener(attrName, value)
} else if(attrName == 'checked') {
if(attrs[attrName]){
result.setAttribute(attrName, "checked")
}
} else {
result.setAttribute(attrName, value)
}
}
}
children.forEach(child => {
const append = child => {
if(typeof(child) == 'undefined') {
throw new Error('illegal state')
} else if(child !== null && child !== false) {
result.appendChild(
typeof(child) == 'string'
? document.createTextNode(child)
: child
)
}
}
if(Array.isArray(child)) {
child.forEach(append)
} else {
append(child)
}
})
return result
}
function fn_link(fn){
// TODO if name is empty or 'anonymous', then show its source code instead
// of name
const str = fn.__location == null
? `<span>${fn.name}</span>`
: `<a href='javascript:void(0)'><i>fn</i> ${fn.name}</a>`
const c = document.createElement('div')
c.innerHTML = str
const el = c.children[0]
if(fn.__location != null) {
el.addEventListener('click', e => {
e.stopPropagation()
exec('goto_location',fn.__location)
})
}
return el
}
export function value_to_dom_el(value) {
return typeof(value) == 'function'
? fn_link(value)
: stringify_for_header(value)
}
export function join(arr, separator = ', ') {
const result = []
for(let i = 0; i < arr.length; i++) {
result.push(arr[i])
if(i != arr.length - 1) {
result.push(separator)
}
}
return result
}
// Idea is borrowed from:
// https://mhk-bit.medium.com/scroll-into-view-if-needed-10a96e0bdb61
// https://stackoverflow.com/questions/37137450/scroll-all-nested-scrollbars-to-bring-an-html-element-into-view
export const scrollIntoViewIfNeeded = (container, target) => {
// Target is outside the viewport from the top
if(target.offsetTop - container.scrollTop - container.offsetTop < 0){
// The top of the target will be aligned to the top of the visible area of the scrollable ancestor
target.scrollIntoView(true);
}
// Target is outside the view from the bottom
if(target.offsetTop - container.scrollTop - container.offsetTop - container.clientHeight + target.clientHeight > 0) {
// The bottom of the target will be aligned to the bottom of the visible area of the scrollable ancestor.
target.scrollIntoView(false);
}
/*
Also works
// Target is outside the view from the top
if (target.getBoundingClientRect().y < container.getBoundingClientRect().y) {
// The top of the target will be aligned to the top of the visible area of the scrollable ancestor
target.scrollIntoView();
}
// Target is outside the view from the bottom
if (
target.getBoundingClientRect().bottom - container.getBoundingClientRect().bottom +
// Adjust for scrollbar size
container.offsetHeight - container.clientHeight
> 0
) {
// The bottom of the target will be aligned to the bottom of the visible area of the scrollable ancestor.
target.scrollIntoView(false);
}
*/
};

View File

@@ -1,641 +0,0 @@
import {exec, get_state, exec_and_reload_app_window} from '../index.js'
import {ValueExplorer} from './value_explorer.js'
import {stringify_for_header} from '../value_explorer_utils.js'
import {el} from './domutils.js'
import {version_number_symbol} from '../calltree.js'
/*
normalize events 'change' and 'changeSelection':
- change is debounced
- changeSelection must not fire if 'change' is fired. So for every keystroke,
either change or changeSelection should be fired, not both
- changeSelection fired only once (ace fires it multiple times for single
keystroke)
*/
const normalize_events = (ace_editor, {
on_change,
on_change_selection,
is_change_selection_supressed,
on_change_immediate,
}) => {
const TIMEOUT = 1000
let state
const set_initial_state = () => {
state = {}
}
set_initial_state()
const flush = () => {
if(state.change_args != null) {
on_change(...state.change_args)
} else if(state.change_selection_args != null) {
on_change_selection(...state.change_selection_args)
}
set_initial_state()
}
ace_editor.on('change', (...args) => {
on_change_immediate()
if(state.tid != null) {
clearTimeout(state.tid)
}
state.change_args = args
state.tid = setTimeout(() => {
state.tid = null
flush()
}, TIMEOUT)
})
ace_editor.on('changeSelection', (...args) => {
// TODO debounce changeSelection?
if(is_change_selection_supressed()) {
return
}
if(state.tid != null) {
// flush is already by `change`, skip `changeSelection`
return
}
state.change_selection_args = args
if(!state.is_flush_set) {
state.is_flush_set = true
Promise.resolve().then(() => {
if(state.tid == null) {
flush()
}
})
}
})
}
export class Editor {
constructor(ui, editor_container){
this.ui = ui
this.editor_container = editor_container
this.make_resizable()
this.markers = {}
this.sessions = {}
this.ace_editor = globalThis.ace.edit(this.editor_container)
this.ace_editor.setOptions({
behavioursEnabled: false,
// Scroll past end for value explorer
scrollPastEnd: 100 /* Allows to scroll 100*<screen size> */,
enableLiveAutocompletion: false,
enableBasicAutocompletion: true,
})
normalize_events(this.ace_editor, {
on_change: () => {
try {
exec_and_reload_app_window('input', this.ace_editor.getValue(), this.get_cursor_position())
} catch(e) {
// Do not throw Error to ACE because it breaks typing
console.error(e)
this.ui.set_status(e.message)
}
},
on_change_immediate: () => {
this.unembed_value_explorer()
},
on_change_selection: () => {
try {
if(!this.is_change_selection_supressed) {
exec('move_cursor', this.get_cursor_position())
}
} catch(e) {
// Do not throw Error to ACE because it breaks typing
console.error(e)
this.ui.set_status(e.message)
}
},
is_change_selection_supressed: () => {
return this.is_change_selection_supressed
}
})
this.focus()
this.init_keyboard()
}
focus() {
this.ace_editor.focus()
}
supress_change_selection(action) {
try {
this.is_change_selection_supressed = true
action()
} finally {
this.is_change_selection_supressed = false
}
}
ensure_session(file, code) {
let session = this.sessions[file]
if(session == null) {
session = globalThis.ace.createEditSession(code)
this.sessions[file] = session
session.setUseWorker(false)
session.setOptions({
mode: "ace/mode/javascript",
tabSize: 2,
useSoftTabs: true,
})
}
return session
}
get_session(file) {
return this.sessions[file]
}
switch_session(file) {
// Supress selection change triggered by switching sessions
this.supress_change_selection(() => {
this.ace_editor.setSession(this.get_session(file))
})
}
has_value_explorer() {
return this.value_explorer != null
}
unembed_value_explorer() {
if(!this.has_value_explorer()) {
return null
}
const session = this.ace_editor.getSession()
const widget_bottom = this.value_explorer.el.getBoundingClientRect().bottom
session.widgetManager.removeLineWidget(this.value_explorer)
if(this.value_explorer.is_dom_el) {
/*
if cursor moves below value_explorer, then ace editor first adjusts scroll,
and then value_explorer gets remove, so scroll jerks. We have to set scroll
back
*/
// distance travelled by cursor
const distance = session.selection.getCursor().row - this.value_explorer.row
if(distance > 0) {
const line_height = this.ace_editor.renderer.lineHeight
const scroll = widget_bottom - this.editor_container.getBoundingClientRect().bottom
if(scroll > 0) {
const scrollTop = session.getScrollTop()
session.setScrollTop(session.getScrollTop() - scroll - line_height*distance)
}
}
}
this.value_explorer = null
}
update_value_explorer_margin() {
if(this.value_explorer != null) {
const session = this.ace_editor.getSession()
// Calculate left margin in such way that value explorer does not cover
// code. It has sufficient left margin so all visible code is to the left
// of it
const lines_count = session.getLength()
let margin = 0
for(
let i = this.value_explorer.row;
i <= this.ace_editor.renderer.getLastVisibleRow();
i++
) {
margin = Math.max(margin, session.getLine(i).length)
}
// Next line sets margin based on whole file
//const margin = this.ace_editor.getSession().getScreenWidth()
this.value_explorer.content.style.marginLeft = (margin + 1) + 'ch'
}
}
embed_value_explorer(
state,
{
node,
index,
length,
result: {
ok,
value,
error,
version_number,
is_calltree_node_explorer
}
}
) {
this.unembed_value_explorer()
const session = this.ace_editor.getSession()
let content
const container = el('div', {'class': 'embed_value_explorer_container'},
el('div', {'class': 'embed_value_explorer_wrapper'},
content = el('div', {
// Ace editor cannot render value_explorer before the first line. So we
// render in on the next line and apply translate
'class': 'embed_value_explorer_content',
tabindex: 0
})
)
)
let initial_scroll_top
const escape = () => {
if(initial_scroll_top != null) {
// restore scroll
session.setScrollTop(initial_scroll_top)
}
if(this.value_explorer.return_to == null) {
this.focus()
} else {
this.value_explorer.return_to.focus()
}
// TODO select root in value explorer
}
container.addEventListener('keydown', e => {
if(e.key == 'Escape') {
escape()
}
})
if(node != null && node.type == 'function_call') {
content.append(el('a', {
href: 'javascript: void(0)',
'class': 'embed_value_explorer_control',
click: () => exec('step_into', index),
}, 'Step into call (Enter)'))
}
let is_dom_el
if(ok) {
if(value instanceof globalThis.app_window.Element && !value.isConnected) {
is_dom_el = true
if(value instanceof globalThis.app_window.SVGElement) {
// Create svg context
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.appendChild(value)
content.appendChild(svg)
} else {
content.appendChild(value)
}
} else {
is_dom_el = false
const exp = new ValueExplorer({
container: content,
event_target: container,
on_escape: escape,
scroll_to_element: t => {
if(initial_scroll_top == null) {
initial_scroll_top = session.getScrollTop()
}
let scroll
const out_of_bottom = t.getBoundingClientRect().bottom - this.editor_container.getBoundingClientRect().bottom
if(out_of_bottom > 0) {
session.setScrollTop(session.getScrollTop() + out_of_bottom)
}
const out_of_top = this.editor_container.getBoundingClientRect().top - t.getBoundingClientRect().top
if(out_of_top > 0) {
session.setScrollTop(session.getScrollTop() - out_of_top)
}
},
})
if(is_calltree_node_explorer) {
exp.render(state, value, {
is_expanded: true,
children: {
'*arguments*': {is_expanded: true},
}
})
} else {
exp.render(
state,
{value, [version_number_symbol]: version_number},
{is_expanded: true},
)
}
}
} else {
is_dom_el = false
content.appendChild(el('span', 'eval_error', stringify_for_header(error)))
}
const value_explorer = this.value_explorer = {
node,
row: is_dom_el
? session.doc.indexToPosition(index + length).row
: session.doc.indexToPosition(index).row,
fixedWidth: true,
el: container,
content,
is_dom_el,
}
if (!session.widgetManager) {
const LineWidgets = require("ace/line_widgets").LineWidgets;
session.widgetManager = new LineWidgets(session);
session.widgetManager.attach(this.ace_editor);
}
if(is_dom_el) {
container.classList.add('is_dom_el')
session.widgetManager.addLineWidget(value_explorer)
} else {
container.classList.add('is_not_dom_el')
const line_height = this.ace_editor.renderer.lineHeight
content.style.transform = `translate(0px, -${line_height}px)`
// hide element before margin applied to avoid jitter
container.style.display = 'none'
session.widgetManager.addLineWidget(value_explorer)
// update_value_explorer_margin relies on getLastVisibleRow which can be
// incorrect because it may be executed right after set_cursor_position
// which is async in ace_editor. Use setTimeout
setTimeout(() => {
this.update_value_explorer_margin()
container.style.display = ''
}, 0)
}
}
focus_value_explorer(return_to) {
if(this.value_explorer != null) {
this.value_explorer.return_to = return_to
this.value_explorer.content.focus({preventScroll: true})
}
}
set_keyboard_handler(type) {
if(type != null) {
localStorage.keyboard = type
}
this.ace_editor.setKeyboardHandler(
type == 'vim' ? "ace/keyboard/vim" : null
)
}
init_keyboard(){
this.set_keyboard_handler(localStorage.keyboard)
// Intercept Enter to execute step_into if function_call selected
this.ace_editor.keyBinding.addKeyboardHandler(($data, hashId, keyString, keyCode, e) => {
if(keyString == 'return') {
if(this.value_explorer?.node?.type == 'function_call') {
exec('step_into', this.value_explorer.node.index)
return {command: "null"} // to stop other handlers
}
}
})
const VimApi = require("ace/keyboard/vim").CodeMirror.Vim
// Remove commands binded to function keys that we are going to redefine
this.ace_editor.commands.removeCommand('openCommandPallete')
this.ace_editor.commands.removeCommand('toggleFoldWidget')
this.ace_editor.commands.removeCommand('goToNextError')
this.ace_editor.commands.bindKey("F5", "goto_definition");
VimApi._mapCommand({
keys: 'gd',
type: 'action',
action: 'aceCommand',
actionArgs: { name: "goto_definition" }
})
this.ace_editor.commands.addCommand({
name: 'goto_definition',
exec: (editor) => {
this.goto_definition()
}
})
this.ace_editor.commands.bindKey("F1", "focus_value_explorer");
this.ace_editor.commands.addCommand({
name: 'focus_value_explorer',
exec: (editor) => {
this.focus_value_explorer()
}
})
this.ace_editor.commands.bindKey("ctrl-i", 'step_into')
VimApi._mapCommand({
keys: '\\i',
type: 'action',
action: 'aceCommand',
actionArgs: { name: "step_into" }
})
this.ace_editor.commands.addCommand({
name: 'step_into',
exec: (editor) => {
exec('step_into', this.get_cursor_position())
}
})
this.ace_editor.commands.bindKey("ctrl-o", 'step_out')
VimApi._mapCommand({
keys: '\\o',
type: 'action',
action: 'aceCommand',
actionArgs: { name: "step_out" }
})
this.ace_editor.commands.addCommand({
name: 'step_out',
exec: (editor) => {
exec('calltree.arrow_left')
}
})
this.ace_editor.commands.addCommand({
name: 'expand_selection',
exec: () => {
exec('eval_selection', this.get_cursor_position(), true)
}
})
this.ace_editor.commands.addCommand({
name: 'collapse_selection',
exec: () => {
exec('eval_selection', this.get_cursor_position(), false)
}
})
this.ace_editor.commands.bindKey("ctrl-j", 'expand_selection')
this.ace_editor.commands.bindKey("ctrl-down", 'expand_selection')
this.ace_editor.commands.bindKey("ctrl-k", 'collapse_selection')
this.ace_editor.commands.bindKey("ctrl-up", 'collapse_selection')
this.ace_editor.commands.addCommand({
name: 'edit',
exec: (editor, input) => {
const module = input.args == null ? '' : input.args[0]
exec('change_current_module', module)
}
})
VimApi.defineEx("edit", "e", function(cm, input) {
cm.ace.execCommand("edit", input)
})
this.ace_editor.commands.addCommand({
name: 'buffer',
exec: (editor, input) => {
const search_query = input.args == null ? '' : input.args[0]
// TODO move to cmd.js
const module = search_query == ''
? ''
: Object.keys(get_state().files).find(name => name.includes(search_query))
if(module != null) {
exec('change_current_module', module)
}
}
})
VimApi.defineEx("buffer", "b", function(cm, input) {
cm.ace.execCommand("buffer", input)
})
// TODO remove my custom binding
VimApi.map('jj', '<Esc>', 'insert')
}
add_marker(file, className, from, to){
const session = this.get_session(file)
const from_pos = session.doc.indexToPosition(from)
const to_pos = session.doc.indexToPosition(to)
const markerId = session.addMarker(
new globalThis.ace.Range(from_pos.row,from_pos.column,to_pos.row,to_pos.column),
className
)
if(this.markers[file] == null){
this.markers[file] = []
}
this.markers[file].push({className, from, to, markerId})
}
remove_markers_of_type(file, type){
if(this.markers[file] == null){
this.markers[file] = []
}
const for_removal = this.markers[file].filter(h => h.className == type)
const session = this.get_session(file)
for(let marker of for_removal){
session.removeMarker(marker.markerId)
}
this.markers[file] = this.markers[file].filter(h => h.className != type)
}
get_cursor_position(file){
const session = file == null
? this.ace_editor.getSession()
: this.get_session(file)
if(session == null) {
// Session was not created for file
throw new Error('illegal state')
}
return session.doc.positionToIndex(session.selection.getCursor())
}
set_cursor_position(index){
if(index == null) {
throw new Error('illegal state')
}
const pos = this.ace_editor.session.doc.indexToPosition(index)
this.supress_change_selection(() => {
const pos = this.ace_editor.session.doc.indexToPosition(index)
this.ace_editor.moveCursorToPosition(pos)
// Moving cursor performs selection, clear it
this.ace_editor.clearSelection()
const first = this.ace_editor.renderer.getFirstVisibleRow()
const last = this.ace_editor.renderer.getLastVisibleRow()
if(pos.row < first || pos.row > last) {
this.ace_editor.scrollToLine(pos.row)
}
})
}
goto_definition(){
const index = this.get_cursor_position()
exec('goto_definition', index)
}
for_each_session(cb) {
for(let file in this.sessions) {
cb(file, this.sessions[file])
}
}
make_resizable() {
const apply_height = () => {
this.editor_container.style.height = localStorage.editor_height + 'vh'
}
let last_resize_time = new Date().getTime()
window.addEventListener('resize', () => {
last_resize_time = new Date().getTime()
})
// Save editor_height on resize and restore it on reopen
if(localStorage.editor_height != null) {
apply_height()
}
let is_first_run = true
new ResizeObserver((e) => {
if(is_first_run) {
// Resize observer callback seems to fire immediately on create
is_first_run = false
return
}
if(new Date().getTime() - last_resize_time < 100) {
// Resize observer triggered by window resize, skip
return
}
// See https://stackoverflow.com/a/57166828/795038
// ace editor must be updated based on container size change
this.ace_editor.resize()
const height = this.editor_container.offsetHeight / window.innerHeight * 100
localStorage.editor_height = height
// resize applies height in pixels. Wait for it and apply height in vh
setTimeout(apply_height, 0)
}).observe(this.editor_container)
}
}

View File

@@ -1,262 +0,0 @@
import {el} from './domutils.js'
import {map_find} from '../utils.js'
import {open_dir, create_file} from '../filesystem.js'
import {examples} from '../examples.js'
import {
exec,
get_state,
open_directory,
exec_and_reload_app_window,
close_directory,
} from '../index.js'
const is_html = path => path.endsWith('.htm') || path.endsWith('.html')
const is_js = path => path == '' || path.endsWith('.js') || path.endsWith('.mjs')
export class Files {
constructor(ui) {
this.ui = ui
this.el = el('div', 'files_container')
this.render(get_state())
}
change_entrypoint(entrypoint, current_module) {
exec_and_reload_app_window('change_entrypoint', entrypoint, current_module)
this.ui.editor.focus()
}
change_html_file(e) {
const html_file = e.target.value
exec_and_reload_app_window('change_html_file', html_file)
}
render(state) {
this.file_to_el = new Map()
const file_actions = state.has_file_system_access
? el('div', 'file_actions',
el('a', {
'class': 'file_action',
href: 'javascript: void(0)',
click: this.create_file.bind(this, false),
},
'New file'
),
el('a', {
'class': 'file_action',
href: 'javascript: void(0)',
click: this.create_file.bind(this, true),
},
'New dir'
),
el('a', {
'class': 'file_action',
href: 'javascript: void(0)',
click: close_directory,
},
'Revoke access'
),
el('a', {
href: 'https://github.com/leporello-js/leporello-js#selecting-entrypoint-module',
target: '__blank',
"class": 'select_entrypoint_title',
title: 'Select entrypoint',
},
'Entry point'
),
)
: el('div', 'file_actions',
el('div', 'file_action allow_file_access',
el('a', {
href: 'javascript: void(0)',
click: open_directory,
}, 'Allow access to local project folder'),
el('span', 'subtitle', `Your files will never leave your device`)
),
)
const file_elements = [
this.render_file({name: '*scratch*', path: ''}, state),
this.render_file(state.project_dir, state),
]
const files = this.el.querySelector('.files')
if(files == null) {
this.el.innerHTML = ''
this.el.appendChild(file_actions)
this.el.appendChild(
el('div', 'files', file_elements)
)
} else {
// Replace to preserve scroll position
this.el.replaceChild(file_actions, this.el.children[0])
files.replaceChildren(...file_elements)
}
}
render_current_module(current_module) {
this.current_file = current_module
this.active_el.querySelector('.file_title').classList.remove('active')
this.active_el = this.file_to_el.get(current_module)
this.active_el.querySelector('.file_title').classList.add('active')
}
render_select_entrypoint(file, state) {
if(!state.has_file_system_access || file.kind == 'directory') {
return null
} else if(is_js(file.path)) {
return el('span', 'select_entrypoint',
el('input', {
type: 'radio',
name: 'js_entrypoint',
value: file.path,
checked: state.entrypoint == file.path,
change: e => this.change_entrypoint(e.target.value),
click: e => e.stopPropagation(),
})
)
} else if(is_html(file.path)) {
return el('span', 'select_entrypoint',
el('input', {
type: 'radio',
name: 'html_file',
value: file.path,
checked: state.html_file == file.path,
change: e => this.change_html_file(e),
click: e => e.stopPropagation(),
})
)
} else {
return null
}
}
render_file(file, state) {
const result = el('div', 'file',
el('div', {
'class': 'file_title' + (file.path == state.current_module ? ' active' : ''),
click: e => this.on_click(e, file)
},
el('span', 'icon',
file.kind == 'directory'
? '\u{1F4C1}' // folder icon
: '\xa0',
),
file.name,
this.render_select_entrypoint(file, state),
),
file.children == null
? null
: file.children.map(c => this.render_file(c, state))
)
this.file_to_el.set(file.path, result)
if(file.path == state.current_module) {
this.active_el = result
this.current_file = file.path
}
return result
}
async create_file(is_dir) {
let name = prompt(`Enter ${is_dir ? 'directory' : 'file'} name`)
if(name == null) {
return
}
const root = get_state().project_dir
let dir, file
if(this.current_file == '' /* scratch */) {
// Create in root directory
dir = root
} else {
const find_file_with_parent = (dir, parent) => {
if(dir.path == this.current_file) {
return [dir, parent]
}
if(dir.children == null) {
return null
}
return map_find(dir.children, c => find_file_with_parent(c, dir))
}
([file, dir] = find_file_with_parent(root))
if(file.kind == 'directory') {
dir = file
} else {
if(dir == null) {
throw new Error('illegal state')
}
}
}
const path = dir == root ? name : dir.path + '/' + name
await create_file(path, is_dir)
// Reload all files for simplicity
open_dir(false).then(dir => {
if(is_dir) {
exec_and_reload_app_window('load_dir', dir, true)
} else {
exec_and_reload_app_window('create_file', dir, path)
}
})
}
on_click(e, file) {
e.stopPropagation()
if(get_state().has_file_system_access) {
if(file.kind != 'directory') {
exec('change_current_module', file.path)
}
} else {
if(file.path == null) {
// root of examples dir, do nothing
} else if(file.path == '') {
this.change_entrypoint('')
} else {
const find_node = n =>
n.path == file.path
||
n.children != null && n.children.find(find_node)
// find example dir
const example_dir = get_state().project_dir.children.find(
c => find_node(c) != null
)
// in examples mode, on click file we also change entrypoint for
// simplicity
const example = examples.find(e => e.path == example_dir.path)
this.change_entrypoint(
example.entrypoint,
file.kind == 'directory' ? undefined : file.path
)
if(example.with_app_window && !localStorage.onboarding_open_app_window) {
this.ui.toggle_open_app_window_tooltip(true)
}
}
}
// Note that we call render_current_module AFTER exec('change_entrypoint'),
// because in case we clicked to example dir, entrypoint would be set, and
// rendered active, so file with entrypoint would be active and not dir we
// clicked, which is weird
this.render_current_module(file.path)
}
}

View File

@@ -1,66 +0,0 @@
import {header, stringify_for_header} from '../value_explorer_utils.js'
import {el} from './domutils.js'
import {has_error} from '../calltree.js'
export class IO_Trace {
constructor(ui, el) {
this.el = el
this.ui = ui
this.el.addEventListener('keydown', (e) => {
if(e.key == 'Escape') {
this.ui.editor.focus()
}
if(e.key == 'F4') {
this.ui.editor.focus()
}
})
}
clear() {
this.el.innerHTML = ''
this.is_rendered = false
}
render_io_trace(state, force) {
if(force) {
this.is_rendered = false
}
if(this.is_rendered) {
return
}
this.is_rendered = true
this.el.innerHTML = ''
const items = state.io_trace ?? []
// Number of items that were used during execution
const used_count = state.rt_cxt.io_trace_index ?? items.length
for(let i = 0; i < items.length; i++) {
const item = items[i]
if(item.type == 'resolution') {
continue
}
const is_used = i < used_count
this.el.appendChild(
el('div',
'call_header '
+ (has_error(item) ? 'error ' : '')
+ (is_used ? '' : 'native '),
item.name,
'(' ,
item.args.map(a => header(a)).join(', '),
'): ' ,
(item.ok ? stringify_for_header(item.value) : item.error.toString())
)
)
}
}
}

View File

@@ -1,98 +0,0 @@
import {el, scrollIntoViewIfNeeded, value_to_dom_el, join} from './domutils.js'
import {exec} from '../index.js'
import {header} from '../value_explorer_utils.js'
import {with_version_number_of_log} from '../cmd.js'
export class Logs {
constructor(ui, el) {
this.el = el
this.ui = ui
this.el.addEventListener('keydown', (e) => {
if(e.key == 'Escape') {
this.ui.editor.focus()
}
if(e.key == 'Enter') {
// TODO reselect call node that was selected previously by calling
// 'calltree.navigate_logs_position'
this.ui.editor.focus()
}
if(e.key == 'F1') {
this.ui.editor.focus_value_explorer(this.el)
}
if(e.key == 'F3') {
this.ui.editor.focus()
}
if(e.key == 'ArrowDown' || e.key == 'j'){
// prevent scroll
e.preventDefault()
exec('calltree.navigate_logs_increment', 1)
}
if(e.key == 'ArrowUp' || e.key == 'k'){
// prevent scroll
e.preventDefault()
exec('calltree.navigate_logs_increment', -1)
}
})
}
rerender_logs(state, logs) {
this.el.innerHTML = ''
this.render_logs(state, null, logs)
}
render_logs(state, prev_logs, logs) {
for(
let i = prev_logs == null ? 0 : prev_logs.logs.length ;
i < logs.logs.length;
i++
)
{
const log = logs.logs[i]
this.el.appendChild(
el('div',
'log call_header '
+ (log.log_fn_name == 'error' ? 'error' : '')
// Currently console.log calls from native fns (like Array::map)
// are not recorded, so next line is dead code
+ (log.module == null ? ' native' : '')
,
el('a', {
href: 'javascript: void(0)',
click: () => exec('calltree.navigate_logs_position', i),
},
(log.module == '' ? '*scratch*' : log.module)
+ ': '
+ (
log.toplevel
? 'toplevel'
: 'fn ' + (log.parent_name == '' ? 'anonymous' : log.parent_name)
)
+ ':'
),
' ',
...join(with_version_number_of_log(state, log, () =>
log.args.map(a => value_to_dom_el(a))
))
)
)
}
if(prev_logs?.log_position != logs.log_position) {
if(prev_logs?.logs == logs.logs && prev_logs?.log_position != null) {
this.el.children[prev_logs.log_position].classList.remove('active')
}
if(logs.log_position != null) {
const active_child = this.el.children[logs.log_position]
active_child.classList.add('active')
scrollIntoViewIfNeeded(this.el, active_child)
}
}
}
}

View File

@@ -1,92 +0,0 @@
import {el} from './domutils.js'
import {save_share} from '../share.js'
import {get_state} from '../index.js'
export class ShareDialog {
constructor() {
this.el = el('dialog', 'share_dialog',
this.upload_begin = el('p', '',
el('p', '',
'This button will upload your scratch file to the cloud for sharing with others.'),
el('ul', '',
el('li', '',
'Please ensure that no personal data or confidential information is included.'
),
el('li', '',
'Avoid including copyrighted materials.'
),
),
el('span', {style: 'color: red'},
'Caution: Once shared, files cannot be deleted.'
),
this.upload_buttons = el('p', {style: 'text-align: center'},
el('button', {
'class': 'upload_button',
click: () => this.upload()
},
"Upload"
),
this.cancel_button = el('button', {
style: 'margin-left: 1em',
click: () => this.cancel()
},
"Cancel"
)
),
),
this.uploading = el('span', {style: 'display: none'},
"Uploading..."
),
this.upload_finish = el('p', {style: 'display: none'},
el('p', '',
el('p', {style: `
text-align: center;
margin-bottom: 1em;
font-size: 1.2em
`}, 'Upload successful'),
this.url_share = el('input', {
type: 'text',
readonly: true,
style: 'min-width: 30em',
}),
this.copy_button = el('button', {
click: () => this.copy(),
style: 'margin-left: 1em',
}, 'Copy URL')
),
this.close_button = el('button', {
style: 'display: block; margin: auto',
click: () => this.cancel(),
}, 'Close'),
)
)
}
async upload() {
this.uploading.style.display = ''
this.upload_begin.style.display = 'none'
try {
await save_share(get_state().files[''])
// window location was changed inside save_share, now it points out to a
// new share
this.url_share.value = window.location.toString()
this.upload_finish.style.display = ''
} catch(e) {
alert(e.message)
this.upload_begin.style.display = ''
} finally {
this.uploading.style.display = 'none'
}
}
copy() {
this.url_share.select()
document.execCommand('copy')
}
cancel() {
this.upload_finish.style.display = 'none'
this.upload_begin.style.display = ''
this.el.close()
}
}

View File

@@ -1,363 +0,0 @@
import {exec, get_state, open_app_window, exec_and_reload_app_window} from '../index.js'
import {Editor} from './editor.js'
import {Files} from './files.js'
import {CallTree} from './calltree.js'
import {Logs} from './logs.js'
import {IO_Trace} from './io_trace.js'
import {ShareDialog} from './share_dialog.js'
import {el} from './domutils.js'
import {redraw_canvas} from '../canvas.js'
export class UI {
constructor(container, state){
this.open_app_window = this.open_app_window.bind(this)
this.files = new Files(this)
this.tabs = {}
this.debugger = {}
container.appendChild(
(this.root = el('div', 'root',
this.editor_container = el('div', 'editor_container'),
el('div', 'bottom',
this.debugger_container = el('div', 'debugger',
this.debugger_loaded = el('div', 'debugger_wrapper',
el('div', 'tabs',
this.tabs.calltree = el('div', 'tab',
el('a', {
click: () => this.set_active_tab('calltree'),
href: 'javascript: void(0)',
}, 'Call tree (F2)')
),
this.tabs.logs = el('div', 'tab',
el('a', {
click: () => this.set_active_tab('logs'),
href: 'javascript: void(0)',
}, 'Logs (F3)')
),
this.tabs.io_trace = el('div', 'tab',
el('a', {
click: () => this.set_active_tab('io_trace'),
href: 'javascript: void(0)',
}, 'IO trace (F4)')
),
),
this.debugger.calltree = el('div', {
'class': 'tab_content',
tabindex: 0,
}),
this.debugger.logs = el('div', {
'class': 'tab_content logs',
tabindex: 0,
}),
this.debugger.io_trace = el('div', {
'class': 'tab_content io_trace',
tabindex: 0,
}),
),
this.debugger_loading = el('div', 'debugger_wrapper',
this.debugger_loading_message = el('div'),
),
),
this.problems_container = el('div', {"class": 'problems_container', tabindex: 0},
this.problems = el('div'),
)
),
this.files.el,
this.statusbar = el('div', 'statusbar',
this.status = el('div', 'status'),
this.current_module = el('div', 'current_module'),
el('a', {
'class': 'statusbar_action first',
href: 'javascript: void(0)',
click: () => this.clear_io_trace(),
},
'Clear IO trace (F6)'
),
el('a', {
'class': 'statusbar_action open_app_window_button',
href: 'javascript: void(0)',
click: this.open_app_window,
},
'(Re)open app window (F7)',
this.open_app_window_tooltip = el('div', {
'class': 'open_app_window_tooltip',
},
'Click here to open app window'
)
),
this.options = el('div', 'options',
el('label', {'for': 'standard'},
el('input', {
id: 'standard',
type: 'radio',
name: 'keyboard',
checked: localStorage.keyboard == 'standard'
|| localStorage.keyboard == null,
change: () => {
this.editor.set_keyboard_handler('standard')
}
}),
'Standard'
),
el('label', {'for': 'vim'},
el('input', {
id: 'vim',
type: 'radio',
name: 'keyboard',
checked: localStorage.keyboard == 'vim',
change: () => {
this.editor.set_keyboard_handler('vim')
}
}),
'VIM'
)
),
el('a', {
'class': 'show_help',
href: 'javascript: void(0)',
click: () => this.help_dialog.showModal(),
},
'Help',
),
el('a', {
'class': 'github',
href: 'https://github.com/leporello-js/leporello-js',
target: '__blank',
}, 'Github'),
el('button', {
'class': 'share_button',
'click': () => this.share_dialog.showModal(),
}, 'Share'),
this.help_dialog = this.render_help(),
this.share_dialog = new ShareDialog().el,
)
))
)
window.addEventListener('keydown', () => this.clear_status(), true)
window.addEventListener('click', () => this.clear_status(), true)
window.addEventListener('keydown', e => {
if(e.key == 'F2') {
this.set_active_tab('calltree')
}
if(e.key == 'F3'){
this.set_active_tab('logs')
}
if(e.key == 'F4'){
this.set_active_tab('io_trace')
}
if(e.key == 'F6'){
this.clear_io_trace()
}
if(e.key == 'F7'){
this.open_app_window()
}
})
this.editor = new Editor(this, this.editor_container)
this.calltree = new CallTree(this, this.debugger.calltree)
this.logs = new Logs(this, this.debugger.logs)
this.io_trace = new IO_Trace(this, this.debugger.io_trace)
this.render_current_module(state.current_module)
this.set_active_tab('calltree', true)
container.addEventListener('focusin', e => {
const active = document.activeElement
let is_focus_in_editor
if(this.editor_container.contains(document.activeElement)) {
if(this.editor.has_value_explorer()) {
// depends on if we come to value explorer from editor or from debugger
is_focus_in_editor = !this.debugger_container.contains(
this.editor.value_explorer.return_to
)
} else {
is_focus_in_editor = true
}
} else {
is_focus_in_editor = false
}
if(this.prev_is_focus_in_editor != is_focus_in_editor) {
this.prev_is_focus_in_editor= is_focus_in_editor
redraw_canvas(get_state(), is_focus_in_editor)
}
})
}
set_active_tab(tab_id, skip_focus = false) {
this.active_tab = tab_id
Object.values(this.tabs).forEach(el => el.classList.remove('active'))
this.tabs[tab_id].classList.add('active')
Object.values(this.debugger).forEach(el => el.style.display = 'none')
this.debugger[tab_id].style.display = 'block'
if(tab_id == 'io_trace') {
this.io_trace.render_io_trace(get_state(), false)
}
if(!skip_focus) {
this.debugger[tab_id].focus()
}
if(tab_id == 'calltree' && !skip_focus) {
exec('calltree.show_value_explorer')
}
}
clear_io_trace() {
exec_and_reload_app_window('clear_io_trace')
}
open_app_window() {
this.toggle_open_app_window_tooltip(false)
localStorage.onboarding_open_app_window = true
open_app_window(get_state())
}
render_debugger_loading(state) {
this.debugger_container.style = ''
this.problems_container.style = 'display: none'
this.debugger_loaded.style = 'display: none'
this.debugger_loading.style = ''
this.debugger_loading_message.innerText =
state.loading_external_imports_state != null
? 'Loading external modules...'
: 'Executing code...'
}
render_debugger(state) {
this.debugger_container.style = ''
this.problems_container.style = 'display: none'
this.debugger_loading.style = 'display: none'
this.debugger_loaded.style = ''
this.calltree.render_calltree(state)
this.logs.render_logs(state, null, state.logs)
}
render_io_trace(state) {
// render lazily, only if selected
if(this.active_tab == 'io_trace') {
this.io_trace.render_io_trace(state, true)
} else {
// Do not render until user switch to the tab
this.io_trace.clear()
}
}
render_problems(problems) {
this.debugger_container.style = 'display: none'
this.problems_container.style = ''
this.problems.innerHTML = ''
problems.forEach(p => {
const s = this.editor.get_session(p.module)
const pos = s.doc.indexToPosition(p.index)
const module = p.module == '' ? "*scratch*" : p.module
this.problems.appendChild(
el('div', 'problem',
el('a', {
href: 'javascript:void(0)',
click: () => exec('goto_problem', p)
},
`${module}:${pos.row + 1}:${pos.column} - ${p.message}`
)
)
)
})
}
set_status(text){
this.current_module.style = 'display: none'
this.status.style = ''
this.status.innerText = text
}
clear_status(){
this.render_current_module(get_state().current_module)
}
render_current_module(current_module) {
this.status.style = 'display: none'
this.current_module.innerText =
current_module == ''
? '*scratch*'
: current_module
this.current_module.style = ''
}
render_help() {
const options = [
['Focus value explorer', 'F1'],
['Navigate value explorer', '← → ↑ ↓ or hjkl'],
['Leave value explorer', 'F1 or Esc'],
['Focus call tree view', 'F2'],
['Navigate call tree view', '← → ↑ ↓ or hjkl'],
['Leave call tree view', 'F2 or Esc'],
['Focus console logs', 'F3'],
['Navigate console logs', '↑ ↓ or jk'],
['Leave console logs', 'F3 or Esc'],
['Focus IO trace', 'F4'],
['Leave IO trace', 'F4 or Esc'],
['Jump to definition', 'F5', 'gd'],
['Expand selection to eval expression', 'Ctrl-↓ or Ctrl-j'],
['Collapse selection', 'Ctrl-↑ or Ctrl-k'],
['Step into call', 'Ctrl-i', '\\i'],
['Step out of call', 'Ctrl-o', '\\o'],
['When in call tree view, jump to return statement', 'Enter'],
['When in call tree view, jump to function arguments', 'a'],
['When in call tree view, jump to error origin', 'e'],
['Clear IO trace', 'F6'],
['(Re)open run window (F7)', 'F7'],
]
return el('dialog', 'help_dialog',
el('table', 'help',
el('thead', '',
el('th', '', 'Action'),
el('th', 'key', 'Standard'),
el('th', 'key', 'VIM'),
),
el('tbody', '',
options.map(([text, standard, vim]) =>
el('tr', '',
el('td', '', text),
el('td',
vim == null
? {'class': 'key spanned', colspan: 2}
: {'class': 'key'},
standard
),
vim == null
? null
: el('td', 'key', vim),
)
)
)
),
el('form', {method: 'dialog'},
el('button', null, 'Close'),
),
)
}
toggle_open_app_window_tooltip(on) {
this.open_app_window_tooltip.classList.toggle('on', on)
}
}

View File

@@ -1,304 +0,0 @@
// TODO paging for large arrays/objects
// TODO show Errors in red
// TODO fns as clickable links (jump to definition), both for header and for
// content
import {el, scrollIntoViewIfNeeded} from './domutils.js'
import {with_code_execution} from '../index.js'
// TODO remove is_expandble, join with displayed entries
import {header, short_header, is_expandable, displayed_entries} from '../value_explorer_utils.js'
import {with_version_number} from '../runtime/runtime.js'
import {is_versioned_object, get_version_number} from '../calltree.js'
const node_props_by_path = (state, o, path) => {
if(is_versioned_object(o)) {
return with_version_number(
state.rt_cxt,
get_version_number(o),
() => node_props_by_path(state, o.value, path),
)
}
if(path.length != 0) {
const [start, ...rest] = path
const value = displayed_entries(o).find(([k,v]) => k == start)[1]
return node_props_by_path(state, value, rest)
} else {
return {
displayed_entries: displayed_entries(o),
header: header(o),
short_header: short_header(o),
is_exp: is_expandable(o),
}
}
}
export class ValueExplorer {
constructor({
container,
event_target = container,
scroll_to_element,
on_escape = () => {},
} = {}
) {
this.container = container
this.scroll_to_element = scroll_to_element
this.on_escape = on_escape
event_target.addEventListener('keydown', e => with_code_execution(() => {
/*
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 toggles expand
*/
if(e.key == 'F1') {
this.on_escape()
return
}
const current_node = node_props_by_path(this.state, this.value, this.current_path)
if(e.key == 'ArrowDown' || e.key == 'j'){
// Do not scroll
e.preventDefault()
if(current_node.is_exp && this.is_expanded(this.current_path)) {
this.select_path(this.current_path.concat(
current_node.displayed_entries[0][0]
))
} else {
const next = p => {
if(p.length == 0) {
return null
}
const parent = p.slice(0, p.length - 1)
const children = node_props_by_path(this.state, this.value, parent)
.displayed_entries
const child_index = children.findIndex(([k,v]) =>
k == p[p.length - 1]
)
const next_child = children[child_index + 1]
if(next_child == null) {
return next(parent)
} else {
return [...parent, next_child[0]]
}
}
const next_path = next(this.current_path)
if(next_path != null) {
this.select_path(next_path)
}
}
}
if(e.key == 'ArrowUp' || e.key == 'k'){
// Do not scroll
e.preventDefault()
if(this.current_path.length == 0) {
this.on_escape()
return
}
const parent = this.current_path.slice(0, this.current_path.length - 1)
const children = node_props_by_path(this.state, this.value, parent).displayed_entries
const child_index = children.findIndex(([k,v]) =>
k == this.current_path[this.current_path.length - 1]
)
const next_child = children[child_index - 1]
if(next_child == null) {
this.select_path(parent)
} else {
const last = p => {
const node_props = node_props_by_path(this.state, this.value, p)
if(!node_props.is_exp || !this.is_expanded(p)) {
return p
} else {
const children = node_props
.displayed_entries
.map(([k,v]) => k)
return last([...p, children[children.length - 1]])
}
}
this.select_path(last([...parent, next_child[0]]))
}
}
if(e.key == 'ArrowLeft' || e.key == 'h'){
// Do not scroll
e.preventDefault()
const is_expanded = this.is_expanded(this.current_path)
if(!current_node.is_exp || !is_expanded) {
if(this.current_path.length != 0) {
const parent = this.current_path.slice(0, this.current_path.length - 1)
this.select_path(parent)
} else {
this.on_escape()
}
} else {
this.toggle_expanded()
}
}
if(e.key == 'ArrowRight' || e.key == 'l'){
// Do not scroll
e.preventDefault()
if(current_node.is_exp) {
const is_expanded = this.is_expanded(this.current_path)
if(!is_expanded) {
this.toggle_expanded()
} else {
const children = node_props_by_path(this.state, this.value, this.current_path)
.displayed_entries
this.select_path(
[
...this.current_path,
children[0][0],
]
)
}
}
}
}))
}
get_node_data(path, node_data = this.node_data) {
if(path.length == 0) {
return node_data
} else {
const [start, ...rest] = path
return this.get_node_data(rest, node_data.children[start])
}
}
is_expanded(path) {
return this.get_node_data(path).is_expanded
}
on_click(path) {
this.select_path(path)
this.toggle_expanded()
}
render(state, value, node_data) {
this.state = state
this.value = value
this.node_data = node_data
const path = []
this.container.appendChild(this.render_value_explorer_node(path, this.node_data))
this.select_path(path)
}
select_path(current_path) {
if(this.current_path != null) {
this.set_active(this.current_path, false)
}
this.current_path = current_path
this.set_active(this.current_path, true)
// Check that was already added to document
if(document.contains(this.container)) {
const target = this.get_node_data(current_path).el.getElementsByClassName('value_explorer_header')[0]
if(this.scroll_to_element == null) {
scrollIntoViewIfNeeded(this.container.parentNode, target)
} else {
this.scroll_to_element(target)
}
}
}
set_active(path, is_active) {
const el = this.get_node_data(path).el.getElementsByClassName('value_explorer_header')[0]
if(is_active) {
el.classList.add('active')
} else {
el.classList.remove('active')
}
}
set_expanded(fn) {
if(typeof(fn) == 'boolean') {
return this.set_expanded(() => fn)
}
const val = this.is_expanded(this.current_path)
const data = this.get_node_data(this.current_path)
data.is_expanded = fn(data.is_expanded)
const prev_dom_node = data.el
const next = this.render_value_explorer_node(this.current_path, data)
prev_dom_node.parentNode.replaceChild(next, prev_dom_node)
}
toggle_expanded() {
this.set_expanded(e => !e)
this.set_active(this.current_path, true)
}
render_value_explorer_node(path, node_data) {
return with_code_execution(() => (
this.do_render_value_explorer_node(path, node_data)
), this.state)
}
do_render_value_explorer_node(path, node_data) {
const key = path.length == 0
? null
: path[path.length - 1]
const {displayed_entries, header, short_header, is_exp}
= node_props_by_path(this.state, this.value, path)
const is_expanded = is_exp && node_data.is_expanded
node_data.children ??= {}
const result = el('div', 'value_explorer_node',
el('span', {
class: 'value_explorer_header',
click: this.on_click.bind(this, path),
},
is_exp
? (is_expanded ? '▼' : '▶')
: '\xa0',
key == null
? null
: el('span', 'value_explorer_key', key.toString(), ': '),
key == null || !is_exp || !is_expanded
// Full header
? header
// Short header
: key == '*arguments*'
? ''
: short_header
),
(is_exp && is_expanded)
? displayed_entries.map(([k,v]) => {
node_data.children[k] ??= {}
return this.do_render_value_explorer_node([...path, k], node_data.children[k])
})
: []
)
node_data.el = result
return result
}
}

352
src/effects.js vendored
View File

@@ -1,352 +0,0 @@
import {write_file} from './filesystem.js'
import {write_example} from './examples.js'
import {color_file} from './color.js'
import {
root_calltree_node,
calltree_node_loc,
get_deferred_calls
} from './calltree.js'
import {current_cursor_position} from './calltree.js'
import {exec, reload_app_window, FILES_ROOT} from './index.js'
import {redraw_canvas} from './canvas.js'
// Imports in the context of `app_window`, so global variables in loaded
// modules refer to that window's context
const import_in_app_window = url => {
return new globalThis.app_window.Function('url', `
return import(url)
`)(url)
}
const load_external_imports = async state => {
if(state.loading_external_imports_state == null) {
return
}
const urls = state.loading_external_imports_state.external_imports
const results = await Promise.allSettled(
urls.map(u => import_in_app_window(
/^\w+:\/\//.test(u)
? // starts with protocol, import as is
u
: // local path, load using File System Access API, see service_worker.js
// Append special URL segment that will be intercepted in service worker
// Note that we use the same origin as current page (where Leporello
// is hosted), so Leporello can access window object for custom
// `html_file`
FILES_ROOT + '/' + u
))
)
const modules = Object.fromEntries(
results.map((r, i) => (
[
urls[i],
{
ok: r.status == 'fulfilled',
error: r.reason,
module: r.value,
}
]
))
)
exec('external_imports_loaded', state /* becomes prev_state */, modules)
}
const ensure_session = (ui, state, file = state.current_module) => {
ui.editor.ensure_session(file, state.files[file])
}
const clear_file_coloring = (ui, file) => {
ui.editor.remove_markers_of_type(file, 'evaluated_ok')
ui.editor.remove_markers_of_type(file, 'evaluated_error')
}
const clear_coloring = ui => {
ui.editor.for_each_session((file, session) => clear_file_coloring(ui, file))
}
const render_coloring = (ui, state) => {
const file = state.current_module
clear_file_coloring(ui, file)
color_file(state, file).forEach(c => {
ui.editor.add_marker(
file,
c.result.ok
? 'evaluated_ok'
: 'evaluated_error',
c.index,
c.index + c.length
)
})
}
const render_parse_result = (ui, state) => {
ui.editor.for_each_session((file, session) => {
ui.editor.remove_markers_of_type(file, 'error-code')
session.clearAnnotations()
})
if(!state.parse_result.ok){
ui.editor.for_each_session((file, session) => {
session.setAnnotations(
state.parse_result.problems
.filter(p => p.module == file)
.map(p => {
const pos = session.doc.indexToPosition(p.index)
return {
row: pos.row,
column: pos.column,
text: p.message,
type: "error",
}
})
)
})
state.parse_result.problems.forEach(problem => {
ensure_session(ui, state, problem.module)
// TODO unexpected end of input
ui.editor.add_marker(
problem.module,
'error-code',
problem.index,
// TODO check if we can show token
problem.token == null
? problem.index + 1
: problem.index + problem.token.length
)
})
ui.render_problems(state.parse_result.problems)
} else {
// Ensure session for each loaded module
Object.keys(state.parse_result.modules).forEach(file => {
ensure_session(ui, state, file)
})
}
}
export const render_initial_state = (ui, state, example) => {
ensure_session(ui, state)
ui.editor.switch_session(state.current_module)
if(
example != null
&& example.with_app_window
&& !localStorage.onboarding_open_app_window
) {
ui.toggle_open_app_window_tooltip(true)
}
}
export const apply_side_effects = (prev, next, ui, cmd) => {
if(prev.project_dir != next.project_dir) {
ui.files.render(next)
}
if(prev.current_module != next.current_module) {
ui.files.render_current_module(next.current_module)
}
if(prev.current_module != next.current_module) {
localStorage.current_module = next.current_module
ui.render_current_module(next.current_module)
}
if(prev.entrypoint != next.entrypoint) {
localStorage.entrypoint = next.entrypoint
}
if(prev.html_file != next.html_file) {
localStorage.html_file = next.html_file
}
if(prev.current_module != next.current_module) {
ensure_session(ui, next)
ui.editor.unembed_value_explorer()
ui.editor.switch_session(next.current_module)
}
// Do not set cursor position on_deferred_call, because editor may be in the middle of the edition operation
if(current_cursor_position(next) != ui.editor.get_cursor_position() && cmd != 'on_deferred_call') {
ui.editor.set_cursor_position(current_cursor_position(next))
}
if(prev.loading_external_imports_state != next.loading_external_imports_state) {
load_external_imports(next)
}
if(
prev.eval_modules_state != next.eval_modules_state
&&
next.eval_modules_state != null
) {
const s = next.eval_modules_state
s.promise.then(result => {
exec('eval_modules_finished',
next, /* becomes prev_state */
result,
)
})
}
if(prev.parse_result != next.parse_result) {
render_parse_result(ui, next)
}
if(!next.parse_result.ok) {
ui.calltree.clear_calltree()
clear_coloring(ui)
} else {
if(
prev.calltree == null
||
prev.calltree_changed_token != next.calltree_changed_token
) {
// code finished executing
const is_loading =
next.loading_external_imports_state != null
||
next.eval_modules_state != null
if(next.rt_cxt?.io_trace_is_replay_aborted) {
reload_app_window()
} else if(is_loading) {
ui.calltree.clear_calltree()
clear_coloring(ui)
ui.render_debugger_loading(next)
} else {
// Rerender entire calltree
ui.render_debugger(next)
clear_coloring(ui)
render_coloring(ui, next)
ui.logs.rerender_logs(next, next.logs)
if(
prev.io_trace != next.io_trace
||
prev.rt_cxt?.io_trace_index != next.rt_cxt.io_trace_index
) {
ui.render_io_trace(next)
}
}
} else {
// code was already executed before current action
if(get_deferred_calls(prev) == null && get_deferred_calls(next) != null) {
ui.calltree.render_deferred_calls(next)
}
if(
prev.calltree != next.calltree
||
prev.calltree_node_is_expanded != next.calltree_node_is_expanded
) {
ui.calltree.render_expand_node(prev, next)
}
const node_changed = next.current_calltree_node != prev.current_calltree_node
if(node_changed) {
ui.calltree.render_select_node(prev, next)
}
if(prev.colored_frames != next.colored_frames) {
render_coloring(ui, next)
}
ui.logs.render_logs(next, prev.logs, next.logs)
// Redraw canvas
if(
prev.current_calltree_node != next.current_calltree_node
||
prev.calltree_node_is_expanded != next.calltree_node_is_expanded
) {
redraw_canvas(next, ui.is_focus_in_editor)
}
}
}
// Render
/* Eval selection */
if(prev.selection_state != next.selection_state) {
ui.editor.remove_markers_of_type(next.current_module, 'selection')
const node = next.selection_state?.node
if(node != null) {
ui.editor.add_marker(
next.current_module,
'selection',
node.index,
node.index + node.length
)
}
}
// Value explorer
if(prev.value_explorer != next.value_explorer) {
if(next.value_explorer == null) {
ui.editor.unembed_value_explorer()
} else {
ui.editor.embed_value_explorer(next, next.value_explorer)
}
}
}
export const EFFECTS = {
set_focus: (_state, _args, ui) => {
ui.editor.focus()
},
set_status: (state, [msg], ui) => {
ui.set_status(msg)
},
save_to_localstorage(state, [key, value]){
localStorage[key] = value
},
write: (state, [name, contents], ui, prev_state) => {
if(name == '') {
const share_id = new URL(window.location).searchParams.get('share_id')
if(share_id == null) {
localStorage['code'] = contents
} else {
const key = 'share_' + share_id
if(localStorage['code'] == prev_state.files['']) {
/*
If scratch code is the same with share code, then update both
Imagine the following scenario:
- User shares code. URL is replaced with ?share_id=XXX
- He keeps working on code
- He closes browser tab and on the next day he opens app.leporello.tech
- His work is lost (actually, he can still access it with
?share_id=XXX, but that not obvious
To prevent that, we keep updating scratch code after sharing
*/
localStorage['code'] = contents
}
localStorage[key] = contents
}
} else if(state.has_file_system_access) {
write_file(name, contents)
} else {
write_example(name, contents)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,97 +0,0 @@
export const write_example = (name, contents) => {
localStorage['examples_' + name] = contents
}
const read_example = name => {
return localStorage['examples_' + name]
}
export const examples = [
{
path: 'github_api',
entrypoint: 'github_api/index.js',
},
{
path: 'fibonacci',
entrypoint: 'fibonacci/index.js',
},
{
path: 'todos-preact',
entrypoint: 'todos-preact/index.js',
with_app_window: true,
files: [
'todos-preact/app.js',
]
},
{
path: 'ethers',
entrypoint: 'ethers/block_by_timestamp.js',
},
{
path: 'plot',
entrypoint: 'plot/index.js',
},
{
path: 'fractal_tree',
entrypoint: 'fractal_tree/fractal_tree.js',
with_app_window: true,
},
{
path: 'animated_fractal_tree',
entrypoint: 'animated_fractal_tree/animated_fractal_tree.js',
with_app_window: true,
},
{
path: 'canvas_animation_bubbles',
entrypoint: 'canvas_animation_bubbles/bubbles.js',
with_app_window: true,
},
].map(e => ({...e, entrypoint: e.entrypoint ?? e.path}))
const files_list = examples
.map(e => {
return (e.files ?? []).concat([e.entrypoint])
})
.flat()
.map(l => l.split('/'))
const get_children = path => {
const children = files_list.filter(l => path.every((elem, i) => elem == l[i] ))
const files = children.filter(c => c.length == path.length + 1)
const dirs = [...new Set(children
.filter(c => c.length != path.length + 1)
.map(c => c[path.length])
)]
return Promise.all(files.map(async f => {
const name = f[path.length]
const filepath = f.slice(0, path.length + 1).join('/')
return {
name,
path: filepath,
kind: 'file',
contents:
read_example(filepath) ??
await fetch(globalThis.location.origin + '/docs/examples/'+ filepath)
.then(r => r.text()),
}
})
.concat(dirs.map(async d => {
const p = [...path, d]
return {
name: d,
path: p.join('/'),
kind: 'directory',
children: await get_children(p),
}
})))
}
export const examples_dir_promise = get_children([]).then(children => {
return {
kind: 'directory',
name: 'examples',
path: null,
children,
}
})

View File

@@ -1,174 +0,0 @@
// code is borrowed from
// https://googlechrome.github.io/samples/service-worker/post-message/
const send_message = (message) => {
return new Promise(function(resolve) {
const messageChannel = new MessageChannel();
messageChannel.port1.onmessage = function(event) {
resolve(event.data)
};
if(navigator.serviceWorker.controller == null) {
// Service worker will be available after reload
window.location.reload()
}
navigator.serviceWorker.controller.postMessage(message,
[messageChannel.port2]);
});
}
export const close_dir = () => {
send_message({type: 'SET_DIR_HANDLE', data: null})
clearInterval(keepalive_interval_id)
keepalive_interval_id = null
}
let dir_handle
let keepalive_interval_id
/*
Service worker is killed by the browser after 40 seconds of inactivity see
https://github.com/mswjs/msw/issues/367
There is hard 5 minute limit on service worker lifetime See
https://chromium.googlesource.com/chromium/src/+/master/docs/security/service-worker-security-faq.md#do-service-workers-live-forever
Keep reviving serivce worker, so when user reloads page, dir_handle is picked
up from service worker
*/
const keep_service_worker_alive = () => {
if(keepalive_interval_id != null) {
return
}
keepalive_interval_id = setInterval(() => {
send_message({type: 'SET_DIR_HANDLE', data: dir_handle})
}, 10_000)
}
const request_directory_handle = async () => {
dir_handle = await globalThis.showDirectoryPicker()
await send_message({type: 'SET_DIR_HANDLE', data: dir_handle})
return dir_handle
}
export const init_window_service_worker = window => {
window.navigator.serviceWorker.ready.then(() => {
window.navigator.serviceWorker.addEventListener('message', e => {
if(e.data.type == 'GET_DIR_HANDLE') {
e.ports[0].postMessage(dir_handle)
}
})
})
}
const load_persisted_directory_handle = () => {
return navigator.serviceWorker.register('service_worker.js')
.then(() => navigator.serviceWorker.ready)
/*
Main window also provides dir_handle to service worker, together with
app_window. app_window provides dir_handle to service worker when it
issues fetch event. If clientId is '' then service worker will try to get
dir_handle from main window
*/
.then(() => init_window_service_worker(globalThis))
.then(() => send_message({type: 'GET_DIR_HANDLE'}))
.then(async h => {
if(h == null || (await h.queryPermission()) != 'granted') {
return null
}
// test if directory handle is valid
try {
await h.entries().next()
} catch(e) {
return null
}
dir_handle = h
return dir_handle
})
}
const file_handle = async (dir_handle, filename, is_directory = false, options) => {
if(typeof(filename) == 'string') {
filename = filename.split('/')
}
const [first, ...rest] = filename
if(rest.length == 0) {
return is_directory
? await dir_handle.getDirectoryHandle(first, options)
: await dir_handle.getFileHandle(first, options)
} else {
const nested_dir_handle = await dir_handle.getDirectoryHandle(first)
return file_handle(nested_dir_handle, rest, is_directory, options)
}
}
export const write_file = async (name, contents) => {
const f_hanlde = await file_handle(dir_handle, name)
// Create a FileSystemWritableFileStream to write to.
const writable = await f_hanlde.createWritable()
// Write the contents of the file to the stream.
await writable.write(contents)
// Close the file and write the contents to disk.
await writable.close()
}
// Blacklist hidden dirs and node_modules
const is_blacklisted = h => h.name == 'node_modules' || h.name.startsWith('.')
const read_file = async handle => {
const file_data = await handle.getFile()
return await file_data.text()
}
const do_open_dir = async (handle, path) => {
if(handle.kind == 'directory') {
const children = []
for await (let [name, h] of handle) {
if(!is_blacklisted(h)) {
children.push(h)
}
}
return {
name: handle.name,
path,
kind: 'directory',
children: (await Promise.all(
children.map(c =>
do_open_dir(c, path == null ? c.name : path + '/' + c.name)
)
)).sort((a, b) => a.name.localeCompare(b.name))
}
} else if(handle.kind == 'file') {
return {
name: handle.name,
path,
kind: 'file',
contents: await read_file(handle)
}
} else {
throw new Error('unknown kind')
}
}
export const create_file = (path, is_dir) => {
return file_handle(
dir_handle,
path,
is_dir,
{create: true}
)
}
export const open_dir = async (should_request_access) => {
let handle
if(should_request_access) {
handle = await request_directory_handle()
} else {
handle = await load_persisted_directory_handle()
}
if(handle == null) {
return null
} else {
keep_service_worker_alive()
}
return do_open_dir(handle, null)
}

View File

@@ -1,365 +0,0 @@
// TODO rename to analyze.js
import {set_push, set_diff, set_union, map_object, map_find, uniq, uniq_by}
from './utils.js'
import {collect_destructuring_identifiers, collect_imports, ancestry, find_leaf}
from './ast_utils.js'
const map_find_definitions = (nodes, mapper) => {
const result = nodes.map(mapper)
const undeclared = result.reduce(
(acc, el) => el.undeclared == null ? acc : acc.concat(el.undeclared),
[]
)
const closed = result.map(r => r.closed).reduce(set_union, new Set())
return {
nodes: result.map(r => r.node),
undeclared,
closed,
}
}
const scope_from_node = n => {
if(n.type == 'import') {
return Object.fromEntries(
n.children.map(i => [i.value, i])
)
} else if(n.type == 'export'){
return scope_from_node(n.binding)
} else if(n.type == 'let' || n.type == 'const') {
return Object.fromEntries(
n.children
.flatMap(collect_destructuring_identifiers)
.map(node => [
node.value, node
])
)
} else if(n.type == 'function_decl') {
// Return null because of hoisting. We take function decls into account
// first before processing statements one by one
return null
} else {
return null
}
}
const add_trivial_definition = node => {
if(node.type == 'identifier') {
return {...node, definition: 'self'}
} else if(['destructuring_default', 'destructuring_rest'].includes(node.type)){
return {...node,
children: [add_trivial_definition(node.name_node), ...node.children.slice(1)]
}
} else if(node.type == 'destructuring_pair') {
return {...node, children: [
node.children[0], // key
add_trivial_definition(node.children[1]), // value
]}
} else if(['array_destructuring', 'object_destructuring'].includes(node.type)) {
return {...node, children: node.children.map(add_trivial_definition)}
} else if (node.type == 'decl_pair') {
return {
...node,
children: [add_trivial_definition(node.children[0]), ...node.children.slice(1)]
}
} else {
console.error(node)
throw new Error('not implemented')
}
}
/*
* The function does these things:
* - For each occurence of identifier, attaches definition to this identifier
* - For each closure, attaches 'closed` property with set of vars it closes
* over
* - Finds undeclared identifiers
*
* `scope` is names that are already defined and can be used immediately.
* `closure_scope` is names that are defined but not yet assigned, but they
* will be assigned by the time the closures would be called
*/
const DEFAULT_GLOBALS = new Set(['leporello']) // Leporello.js API
export const find_definitions = (ast, globals, scope = {}, closure_scope = {}, module_name) => {
// sanity check
if(!(globals instanceof Set)) {
throw new Error('not a set')
}
if(ast.type == 'identifier'){
if(ast.definition != null) {
// Definition previously added by add_trivial_definition
return {node: ast, undeclared: null, closed: new Set([ast.value])}
} else {
const definition = scope[ast.value]
if(definition == null){
if(globals.has(ast.value) || DEFAULT_GLOBALS.has(ast.value)) {
return {node: {...ast, definition: 'global'}, undeclared: null, closed: new Set()}
} else {
return {node: ast, undeclared: [ast], closed: new Set()}
}
} else {
return {
node: {...ast, definition: {index: definition.index}},
undeclared: null,
closed: new Set([ast.value])
}
}
}
} else if(ast.type == 'do'){
const hoisted_functions_scope = Object.fromEntries(
ast.children
.filter(s => s.type == 'function_decl')
.map(s => [s.children[0].name, s.children[0]])
)
const children_with_scope = ast.children
.reduce(
({scope, children}, node) => ({
scope: {...scope, ...scope_from_node(node)},
children: children.concat([{node, scope}]),
})
,
{scope: hoisted_functions_scope, children: []}
)
const local_scope = children_with_scope.scope
const {nodes, undeclared, closed} = map_find_definitions(children_with_scope.children, cs =>
find_definitions(cs.node, globals, {...scope, ...cs.scope}, local_scope, module_name)
)
return {
node: {...ast, children: nodes},
undeclared,
closed: set_diff(closed, new Set(Object.keys(local_scope))),
}
} else if (ast.type == 'function_expr'){
const args_identifiers = collect_destructuring_identifiers(ast.function_args)
const args_scope = Object.fromEntries(args_identifiers.map(a => [
a.value, a
]))
const {nodes, undeclared, closed} = map_find_definitions(ast.children,
node => find_definitions(node, globals, {...scope, ...closure_scope, ...args_scope})
)
const next_closed = set_diff(closed, new Set(args_identifiers.map(a => a.value)))
return {
node: {...ast, children: nodes, closed: next_closed},
undeclared,
closed: new Set(),
}
} else if(ast.children != null){
let children, full_import_path
if(ast.type == 'import') {
full_import_path = concat_path(module_name, ast.module)
children = ast.children.map((c, i) => ({
...c,
definition: {
module: full_import_path,
is_default: i == 0 && ast.default_import != null,
}
}))
} else if(ast.type == 'const' || ast.type == 'let') {
children = ast.children.map(add_trivial_definition)
} else {
children = ast.children
}
const {nodes, undeclared, closed} = map_find_definitions(children,
c => find_definitions(c, globals, scope, closure_scope)
)
return {
node: ast.type == 'import'
? {...ast, children: nodes, full_import_path}
: {...ast, children: nodes},
undeclared,
closed
}
} else {
return {node: ast, undeclared: null, closed: new Set()}
}
}
const BASE = 'dummy://dummy/'
const concat_path = (base, i) => {
const result = new URL(i, BASE + base).toString()
if(result.lastIndexOf(BASE) == 0) {
return result.replace(BASE, '')
} else {
return result
}
}
export const topsort_modules = (modules) => {
const sort_module_deps = (module) => {
return collect_imports(modules[module])
.map(m => sort_module_deps(m))
.flat()
.concat(module)
}
const sorted = Object.keys(modules).reduce(
(result, module) => result.concat(sort_module_deps(module)),
[]
)
// now remove duplicates
// quadratic, but N is supposed to be small
return sorted.reduce(
(result, elem) =>
result.includes(elem)
? result
: [...result, elem]
,
[]
)
}
export const has_toplevel_await = modules =>
Object.values(modules).some(m => node_has_toplevel_await(m))
const node_has_toplevel_await = node => {
if(node.type == 'unary' && node.operator == 'await') {
return true
}
if(node.type == 'function_expr') {
return false
}
if(node.children == null) {
return false
}
return node.children.find(c => node_has_toplevel_await(c)) != null
}
// TODO not implemented
// TODO detect cycles when loading modules
export const check_imports = modules => {
// TODO allow circular imports
return map_object(modules, (module, node) => {
const imports = node.children
.filter(n => n.type == 'import')
.reduce(
(imports, n) => [
...imports,
// TODO imports
// TODO use flatmap
...(n.imports.map(i => ({name: i.value, from: n.module})))
],
[]
)
const exports = node.statement
.filter(n => n.type == 'export')
.map(n => collect_destructuring_identifiers(n.binding.name_node))
.reduce((all, current) => [...all, ...current], [])
return {imports, exports}
})
}
/*
code analysis:
- name is declared once and only once (including function args). Name can be imported once
- every assignment can only be to identifier is earlier declared by let
- cannot import names that are not exported from modules.If there is default import from module, there should be default export
- module can be imported either as external or regular
- cannot return from modules toplevel
- await only in async fns
- import only from toplevel
*/
export const analyze = (node, is_toplevel = true) => {
return [
...analyze_await(node, true),
...named_declared_once(node),
]
}
const collect_problems = (node, context, collector) => {
const {context: next_context, problems: node_problems} = collector(node, context)
if(node.children == null) {
return node_problems
}
return node.children.reduce(
(problems, c) => {
const ps = collect_problems(c, next_context, collector)
if(ps == null) {
return problems
}
if(problems == null) {
return ps
}
return problems.concat(ps)
},
node_problems
)
}
const analyze_await = (node, is_async_context = true) => {
const result = collect_problems(node, is_async_context, (node, is_async_context) => {
if(node.type == 'function_expr') {
return {problems: null, context: node.is_async}
}
if(node.type == 'unary' && node.operator == 'await' && !is_async_context) {
const _await = node.children[0]
const problem = {
index: _await.index,
length: _await.length,
message: 'await is only valid in async functions and the top level bodies of modules',
}
return {problems: [problem], context: is_async_context}
}
return {problems: null, context: is_async_context}
})
return result ?? []
}
const find_duplicates = names => {
const duplicates = names.filter((n, i) =>
names.find((name, j) => name.value == n.value && j < i) != null
)
const problems = duplicates.map(d => ({
index: d.index,
length: d.length,
message: `Identifier '${d.value}' has already been declared`,
}))
return problems
}
const named_declared_once = node => {
return collect_problems(node, null, (node, cxt) => {
if(node.type == 'function_expr') {
const names = collect_destructuring_identifiers(node.function_args)
return {
context: uniq_by(names, n => n.value),
problems: find_duplicates(names),
}
} else if(node.type == 'do') {
const names = node
.children
.map(c => {
if(c.type == 'function_decl') {
const function_expr = c.children[0]
return {
value: function_expr.name,
index: function_expr.index,
length: function_expr.name.length
}
} else {
const scope = scope_from_node(c)
return scope == null
? null
: Object.values(scope)
}
})
.flat()
.filter(n => n != null)
const problems = find_duplicates(
[...(cxt ?? []), ...names]
)
return {context: null, problems}
} else {
return {context: null, problems: null}
}
})
}

View File

@@ -1,335 +0,0 @@
import {UI} from './editor/ui.js'
import {EFFECTS, render_initial_state, apply_side_effects} from './effects.js'
import {
open_dir,
close_dir,
init_window_service_worker
} from './filesystem.js'
import {examples, examples_dir_promise} from './examples.js'
import {get_share} from './share.js'
const EXAMPLE = `function fib(n) {
if(n == 0 || n == 1) {
return n
} else {
return fib(n - 1) + fib(n - 2)
}
}
fib(6)
`
const set_error_handler = (w, with_unhandled_rejection = true) => {
// TODO err.message
w.onerror = (msg, src, lineNum, colNum, err) => {
if(err?.__ignore) {
return
}
ui.set_status(msg)
}
if(with_unhandled_rejection) {
w.addEventListener('unhandledrejection', (event) => {
const error = event.reason
if(error.__ignore) {
return
}
ui.set_status(error)
})
}
}
// Fake directory, http requests to this directory intercepted by service_worker
export const FILES_ROOT = new URL('./__leporello_files', globalThis.location)
const get_html_url = state => {
const base = FILES_ROOT + '/'
return state.html_file == ''
? base + '__leporello_blank.html'
: base + state.html_file + '?leporello'
}
const on_window_load = w => {
init_window_service_worker(w)
exec(
'run_code',
new Set(Object.getOwnPropertyNames(w))
)
}
// By default run code in hidden iframe, until user explicitly opens visible
// window
let iframe
const open_app_iframe = (state) => {
iframe = document.createElement('iframe')
iframe.src = get_html_url(state)
iframe.setAttribute('hidden', '')
document.body.appendChild(iframe)
// for app_window, do not set unhandled rejection, because having rejected
// promises in user code is normal condition
set_error_handler(iframe.contentWindow, false)
globalThis.app_window = iframe.contentWindow
init_app_window(globalThis.app_window)
}
// Open another browser window so user can interact with application
// TODO test in another browsers
export const open_app_window = state => {
// TODO set_error_handler? Or we dont need to set_error_handler for child
// window because error is always caught by parent window handler?
exec('open_app_window')
globalThis.app_window.close()
globalThis.app_window = open(get_html_url(state))
init_app_window(globalThis.app_window)
}
const init_app_window = w => {
const is_loaded = () => {
const nav = w.performance.getEntriesByType("navigation")[0]
return nav != null && nav.loadEventEnd > 0
}
const add_load_handler = () => {
/*
Wait until 'load event', then set unload handler. The page after
window.open seems to go through these steps:
- about:blank gets opened
- Real URL get opened
- 'unload' event for about:blank page
- 'load event for real URL
if we set unload handler right now, then it will be fired for unload
event for about:blank page
*/
if(is_loaded()) {
// Already loaded
add_unload_handler()
on_window_load(w)
} else {
w.addEventListener('load', () => {
add_unload_handler()
// Wait until `load` event before executing code, because service worker that
// is responsible for loading external modules seems not working until `load`
// event fired. TODO: better register SW explicitly and don't rely on
// already registered SW?
on_window_load(w)
})
}
}
const add_unload_handler = () => {
w.addEventListener('unload', (e) => {
// Set timeout to 100ms because it takes some time for page to get closed
// after triggering 'unload' event
setTimeout(() => {
if(w.closed && w == globalThis.app_window) {
// If by that time w.closed was set to true, then page was
// closed. Get back to using iframe
globalThis.app_window = iframe.contentWindow
reload_app_window()
} else {
add_load_handler()
}
}, 100)
})
}
add_load_handler()
}
export const reload_app_window = (state = get_state()) => {
// after window location reload, `run_code` command will be fired.
globalThis.app_window.location = get_html_url(state)
}
const get_entrypoint_settings = () => {
return {
current_module: localStorage.current_module ?? '',
entrypoint: localStorage.entrypoint ?? '',
html_file: localStorage.html_file ?? '',
}
}
export const exec_and_reload_app_window = (...exec_args) => {
exec(...exec_args)
reload_app_window()
}
export const open_directory = () => {
if(globalThis.showDirectoryPicker == null) {
throw new Error('Your browser is not supporting File System Access API')
}
open_dir(true).then(dir => {
exec_and_reload_app_window('load_dir', dir, true, get_entrypoint_settings())
})
}
export const close_directory = async () => {
close_dir()
exec_and_reload_app_window('load_dir', await examples_dir_promise, false, get_entrypoint_settings())
}
let COMMANDS
let ui
let state
export const init = async (container, _COMMANDS) => {
COMMANDS = _COMMANDS
set_error_handler(window)
let files = {'': localStorage.code || EXAMPLE}
let initial_state, entrypoint_settings
const project_dir = await open_dir(false)
let example
if(project_dir == null) {
/*
extract example_id from URL params and delete it (because we dont want to
persist in on refresh)
*/
const params = new URLSearchParams(window.location.search)
const example_path = params.get('example')
const nextURL = new URL(window.location)
nextURL.searchParams.delete('example')
history.replaceState(null, null, nextURL.href)
example = examples.find(e => e.path == example_path)
if(example == null) {
const shared_code = await get_share()
if(shared_code == null) {
entrypoint_settings = get_entrypoint_settings()
} else {
files = {'': shared_code}
entrypoint_settings = {
current_module: '',
entrypoint: '',
}
}
} else {
entrypoint_settings = {
current_module: example.entrypoint,
entrypoint: example.entrypoint,
}
}
initial_state = {
project_dir: await examples_dir_promise,
files,
has_file_system_access: false,
}
} else {
entrypoint_settings = get_entrypoint_settings()
initial_state = {
project_dir,
files,
has_file_system_access: true,
}
}
state = COMMANDS.get_initial_state(
{
...initial_state,
on_deferred_call: (...args) => exec('on_deferred_call', ...args)
},
entrypoint_settings,
)
// Expose state for debugging
globalThis.__state = state
ui = new UI(container, state)
// Expose for debugging
globalThis.__ui = ui
render_initial_state(ui, state, example)
open_app_iframe(state)
}
export const get_state = () => state
export const with_code_execution = (action, state = get_state()) => {
/*
supress is_recording_deferred_calls while rendering, because rendering may
call toJSON(), which can call trigger deferred call (see lodash.js lazy
chaining)
*/
if(state.rt_cxt != null) {
state.rt_cxt.is_recording_deferred_calls = false
state.rt_cxt.skip_save_ct_node_for_path = true
}
try {
return action()
} finally {
if(state.rt_cxt != null) {
state.rt_cxt.is_recording_deferred_calls = true
state.rt_cxt.skip_save_ct_node_for_path = false
}
}
}
export const exec = (cmd, ...args) => {
if(cmd == 'input' || cmd == 'write') {
// Do not print file to console
console.log('exec', cmd)
} else {
console.log('exec', cmd, ...args)
}
const comm = cmd.split('.').reduce(
(comm, segment) => comm?.[segment],
COMMANDS
)
if(comm == null) {
throw new Error('command ' + cmd + ' + not found')
}
const result = comm(state, ...args)
console.log('nextstate', result)
let nextstate, effects
if(result.state != null) {
({state: nextstate, effects} = result)
} else {
nextstate = result
effects = null
}
// Sanity check
if(state?.current_module == null) {
console.error('command did not return state, returned', result)
throw new Error('illegal state')
}
// Wrap with_code_execution, because rendering values can trigger execution
// of code by toString() and toJSON() methods
with_code_execution(() => {
apply_side_effects(state, nextstate, ui, cmd)
if(effects != null) {
(Array.isArray(effects) ? effects : [effects]).forEach(e => {
if(e.type == 'write' || e.type == 'save_to_localstorage') {
// do not spam to console
console.log('apply effect', e.type)
} else {
console.log('apply effect', e.type, ...(e.args ?? []))
}
EFFECTS[e.type](nextstate, e.args, ui, state)
})
}
}, nextstate)
// Expose for debugging
globalThis.__prev_state = state
globalThis.__state = nextstate
state = nextstate
}

View File

@@ -1,6 +0,0 @@
// external
import {init} from './index.js'
import {COMMANDS} from './cmd.js'
init(globalThis.document.getElementById('app'), COMMANDS)

File diff suppressed because it is too large Load Diff

View File

@@ -1,48 +0,0 @@
// See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#keywords
export const reserved = [
'break',
'case',
'catch',
'class',
'const',
'continue',
'debugger',
'default',
'delete',
'do',
'else',
'export',
'extends',
'finally',
'for',
'function',
'if',
'import',
'in',
'instanceof',
'new',
'return',
'super',
'switch',
'this',
'throw',
'try',
'typeof',
'var',
'void',
'while',
'with',
'yield',
'enum',
'implements',
'interface',
'let',
'package',
'private',
'protected',
'public',
'static',
'yield',
'await',
]

View File

@@ -1,142 +0,0 @@
import {Multiversion, rollback_if_needed, wrap_methods, mutate} from './multiversion.js'
function set(prop, value) {
this[prop] = value
}
export const defineMultiversionArray = window => {
// We declare class in such a weird name to have its displayed name to be
// exactly 'Array'
window.MultiversionArray = class Array extends window.Array {
constructor(initial, cxt) {
super()
this.multiversion = new Multiversion(cxt)
this.initial = [...initial]
this.redo_log = []
this.apply_initial()
}
apply_initial() {
super.length = this.initial.length
for(let i = 0; i < this.initial.length; i++) {
this[i] = this.initial[i]
}
}
static get [Symbol.species]() {
return globalThis.Array
}
}
wrap_methods(
window.MultiversionArray,
[
'at',
'concat',
'copyWithin',
'entries',
'every',
'fill',
'filter',
'find',
'findIndex',
'findLast',
'findLastIndex',
'flat',
'flatMap',
'forEach',
'includes',
'indexOf',
'join',
'keys',
'lastIndexOf',
'map',
'pop',
'push',
'reduce',
'reduceRight',
'reverse',
'shift',
'slice',
'some',
'sort',
'splice',
'toLocaleString',
'toReversed',
'toSorted',
'toSpliced',
'toString',
'unshift',
'values',
'with',
Symbol.iterator,
],
[
'copyWithin',
'fill',
'pop',
'push',
'reverse',
'shift',
'sort',
'splice',
'unshift',
]
)
}
const methods_that_return_self = new Set([
'copyWithin',
'fill',
'reverse',
'sort',
])
export function wrap_array(initial, cxt) {
const array = new cxt.window.MultiversionArray(initial, cxt)
const handler = {
get(target, prop, receiver) {
rollback_if_needed(target)
const result = target[prop]
if(
typeof(prop) == 'string'
&& isNaN(Number(prop))
&& typeof(result) == 'function'
) {
if(methods_that_return_self.has(prop)) {
// declare object with key prop for function to have a name
return {
[prop]() {
result.apply(target, arguments)
return receiver
}
}[prop]
} else {
return {
[prop]() {
return result.apply(target, arguments)
}
}[prop]
}
} else {
return result
}
},
set(obj, prop, val) {
mutate(obj, set, [prop, val])
return true
},
}
return new Proxy(array, handler)
}
export function create_array(initial, cxt, index, literals) {
const result = wrap_array(initial, cxt)
literals.set(index, result)
return result
}

View File

@@ -1,61 +0,0 @@
import {Multiversion} from './multiversion.js'
// https://stackoverflow.com/a/29018745
function binarySearch(arr, el, compare_fn) {
let m = 0;
let n = arr.length - 1;
while (m <= n) {
let k = (n + m) >> 1;
let cmp = compare_fn(el, arr[k]);
if (cmp > 0) {
m = k + 1;
} else if(cmp < 0) {
n = k - 1;
} else {
return k;
}
}
return ~m;
}
export class LetMultiversion extends Multiversion {
constructor(cxt, initial) {
super(cxt)
this.latest = initial
this.versions = [{version_number: cxt.version_counter, value: initial}]
}
rollback_if_needed() {
if(this.needs_rollback()) {
this.latest = this.get_version(this.cxt.version_counter)
}
}
get() {
this.rollback_if_needed()
return this.latest
}
set(value) {
this.rollback_if_needed()
const version_number = ++this.cxt.version_counter
if(this.is_created_during_current_expansion()) {
this.versions.push({version_number, value})
}
this.latest = value
}
get_version(version_number) {
if(version_number == null) {
throw new Error('illegal state')
}
const idx = binarySearch(this.versions, version_number, (id, el) => id - el.version_number)
if(idx >= 0) {
return this.versions[idx].value
} else if(idx == -1) {
throw new Error('illegal state')
} else {
return this.versions[-idx - 2].value
}
}
}

View File

@@ -1,45 +0,0 @@
import {Multiversion, wrap_methods, rollback_if_needed} from './multiversion.js'
export const defineMultiversionMap = window => {
// We declare class in such a weird name to have its displayed name to be
// exactly 'Map'
window.MultiversionMap = class Map extends window.Map {
constructor(initial, cxt) {
super()
this.multiversion = new Multiversion(cxt)
this.initial = new globalThis.Map(initial)
this.redo_log = []
this.apply_initial()
}
apply_initial() {
super.clear()
for(let [k,v] of this.initial) {
super.set(k,v)
}
}
get size() {
rollback_if_needed(this)
return super.size
}
}
wrap_methods(
window.MultiversionMap,
// all methods
[
'clear', 'delete', 'entries', 'forEach', 'get', 'has', 'keys', 'set', 'values',
Symbol.iterator,
],
// mutation methods
['set', 'delete', 'clear'],
)
}

View File

@@ -1,91 +0,0 @@
export class Multiversion {
constructor(cxt) {
this.cxt = cxt
this.ct_expansion_id = cxt.ct_expansion_id
}
is_created_during_current_expansion() {
return this.ct_expansion_id == this.cxt.ct_expansion_id
}
needs_rollback() {
if(this.cxt.is_expanding_calltree_node) {
if(this.is_created_during_current_expansion()) {
// do nothing, keep using current version
} else {
if(this.rollback_expansion_id == this.cxt.ct_expansion_id) {
// do nothing, keep using current version
// We are in the same expansion rollback was done, keep using current version
} else {
this.rollback_expansion_id = this.cxt.ct_expansion_id
return true
}
}
} else {
if(this.rollback_expansion_id != null) {
this.rollback_expansion_id = null
return true
} else {
// do nothing
}
}
}
}
export function rollback_if_needed(object) {
if(object.multiversion.needs_rollback()) {
// Rollback to initial value
object.apply_initial()
// Replay redo log
for(let i = 0; i < object.redo_log.length; i++) {
const log_item = object.redo_log[i]
if(log_item.version_number > object.multiversion.cxt.version_counter) {
break
}
log_item.method.apply(object, log_item.args)
}
}
}
function wrap_readonly_method(clazz, method) {
const original = clazz.__proto__.prototype[method]
clazz.prototype[method] = {
[method](){
rollback_if_needed(this)
return original.apply(this, arguments)
}
}[method]
}
export function mutate(object, method, args) {
rollback_if_needed(object)
const version_number = ++object.multiversion.cxt.version_counter
if(object.multiversion.is_created_during_current_expansion()) {
object.redo_log.push({
method,
args,
version_number,
})
}
return method.apply(object, args)
}
function wrap_mutating_method(clazz, method) {
const original = clazz.__proto__.prototype[method]
clazz.prototype[method] = {
[method]() {
return mutate(this, original, arguments)
}
}[method]
}
export function wrap_methods(clazz, all_methods, mutating_methods) {
for (let method of all_methods) {
if(mutating_methods.includes(method)) {
wrap_mutating_method(clazz, method)
} else {
wrap_readonly_method(clazz, method)
}
}
}

View File

@@ -1,68 +0,0 @@
import {Multiversion, rollback_if_needed, wrap_methods, mutate} from './multiversion.js'
export function create_object(initial, cxt, index, literals) {
const multiversion = new Multiversion(cxt)
let latest = {...initial}
const redo_log = []
function rollback_if_needed() {
if(multiversion.needs_rollback()) {
latest = {...initial}
for(let i = 0; i < redo_log.length; i++) {
const log_item = redo_log[i]
if(log_item.version_number > multiversion.cxt.version_counter) {
break
}
if(log_item.type == 'set') {
latest[log_item.prop] = log_item.value
} else if(log_item.type == 'delete') {
delete latest[log_item.prop]
} else {
throw new Error('illegal type')
}
}
}
}
const handler = {
get(target, prop, receiver) {
rollback_if_needed()
return latest[prop]
},
has(target, prop) {
rollback_if_needed()
return prop in latest
},
set(obj, prop, value) {
rollback_if_needed()
const version_number = ++multiversion.cxt.version_counter
if(multiversion.is_created_during_current_expansion()) {
redo_log.push({ type: 'set', prop, value, version_number })
}
latest[prop] = value
return true
},
ownKeys(target) {
rollback_if_needed()
return Object.keys(latest)
},
getOwnPropertyDescriptor(target, prop) {
rollback_if_needed()
return {
configurable: true,
enumerable: true,
value: latest[prop],
};
},
// TODO delete property handler
}
const result = new Proxy(initial, handler)
literals.set(index, result)
return result
}

View File

@@ -1,355 +0,0 @@
import {set_record_call} from './runtime.js'
const io_patch = (window, path, use_context = false) => {
let obj = window
for(let i = 0; i < path.length - 1; i++) {
obj = obj[path[i]]
}
const method = path.at(-1)
if(obj == null || obj[method] == null) {
// Method is absent in current env, skip patching
return
}
const name = path.join('.')
const original = obj[method]
obj[method] = make_patched_method(window, original, name, use_context)
obj[method].__original = original
}
export const abort_replay = (cxt) => {
cxt.io_trace_is_replay_aborted = true
cxt.io_trace_abort_replay()
// throw error to prevent further code execution. It
// is not necesseary, because execution would not have
// any effects anyway
const error = new Error('io replay aborted')
error.__ignore = true
throw error
}
const patched_method_prolog = (window, original, that, has_new_target, args) => {
const cxt = window.__cxt
if(cxt.io_trace_is_replay_aborted) {
// Try to finish fast
const error = new Error('io replay was aborted')
error.__ignore = true
throw error
}
// save call, so on expand_call and find_call IO functions would not be
// called.
// TODO: we have a problem when IO function is called from third-party
// lib and async context is lost
set_record_call(cxt)
if(cxt.is_recording_deferred_calls) {
// TODO record trace on deferred calls?
const value = has_new_target
? new original(...args)
: original.apply(that, args)
return {is_return: true, value}
}
return {is_return: false}
}
const make_patched_method = (window, original, name, use_context) => {
const method = function(...args) {
const has_new_target = new.target != null
const {value, is_return} = patched_method_prolog(
window,
original,
this,
has_new_target,
args
)
if(is_return) {
return value
}
const cxt = window.__cxt
const cxt_copy = cxt
if(cxt.io_trace_is_recording) {
let ok, value, error
try {
const index = cxt.io_trace.length
if(name == 'setTimeout') {
args = args.slice()
// Patch callback
const cb = args[0]
args[0] = Object.defineProperty(function() {
if(cxt_copy != cxt) {
// If code execution was cancelled, then never call callback
return
}
if(cxt.io_trace_is_replay_aborted) {
// Non necessary
return
}
cxt.io_trace.push({type: 'resolution', index})
cb()
}, 'name', {value: cb.name})
}
value = has_new_target
? new original(...args)
: original.apply(this, args)
if(value?.[Symbol.toStringTag] == 'Promise') {
value = value
.then(val => {
value.status = {ok: true, value: val}
return val
})
.catch(error => {
value.status = {ok: true, error}
throw error
})
.finally(() => {
if(cxt_copy != cxt) {
return
}
if(cxt.io_trace_is_replay_aborted) {
// Non necessary
return
}
cxt.io_trace.push({type: 'resolution', index})
})
}
ok = true
return value
} catch(e) {
error = e
ok = false
throw e
} finally {
cxt.io_trace.push({
type: 'call',
name,
ok,
value,
error,
args,
// To discern calls with and without 'new' keyword, primary for
// Date that can be called with and without new
has_new_target,
use_context,
context: use_context ? this : undefined,
})
}
} else {
// IO trace replay
const call = cxt.io_trace[cxt.io_trace_index]
// TODO if call == null or call.type == 'resolution', then do not discard
// trace, instead switch to record mode and append new calls to the
// trace?
if(
call == null
|| call.type != 'call'
|| call.has_new_target != has_new_target
|| call.use_context && (call.context != this)
|| call.name != name
|| (
(name == 'setTimeout' && (args[1] != call.args[1])) /* compares timeout*/
||
(
name != 'setTimeout'
&&
JSON.stringify(call.args) != JSON.stringify(args)
)
)
){
abort_replay(cxt)
} else {
const next_resolution = cxt.io_trace.find((e, i) =>
e.type == 'resolution' && i > cxt.io_trace_index
)
if(next_resolution != null && !cxt.io_trace_resolver_is_set) {
cxt.io_trace_resolver_is_set = true
// use setTimeout function from host window (because this module was
// loaded as `external` by host window)
setTimeout(() => {
if(cxt_copy != cxt) {
return
}
if(cxt.io_trace_is_replay_aborted) {
return
}
cxt.io_trace_resolver_is_set = false
// Sanity check
if(cxt.io_trace_index >= cxt.io_trace.length) {
throw new Error('illegal state')
}
const next_event = cxt.io_trace[cxt.io_trace_index]
if(next_event.type == 'call') {
// TODO use abort_replay
cxt.io_trace_is_replay_aborted = true
cxt.io_trace_abort_replay()
} else {
while(
cxt.io_trace_index < cxt.io_trace.length
&&
cxt.io_trace[cxt.io_trace_index].type == 'resolution'
) {
const resolution = cxt.io_trace[cxt.io_trace_index]
const {resolve, reject} = cxt.io_trace_resolvers.get(resolution.index)
cxt.io_trace_index++
if(cxt.io_trace[resolution.index].name == 'setTimeout') {
resolve()
} else {
const promise = cxt.io_trace[resolution.index].value
if(promise.status == null) {
throw new Error('illegal state')
}
if(promise.status.ok) {
resolve(promise.status.value)
} else {
reject(promise.status.error)
}
}
}
}
}, 0)
}
cxt.io_trace_index++
if(call.ok) {
// Use Symbol.toStringTag for comparison because Promise may
// originate from another window (if window was reopened after record
// trace) and instanceof would not work
if(call.value?.[Symbol.toStringTag] == 'Promise') {
// Always make promise originate from app_window
return new cxt.window.Promise((resolve, reject) => {
cxt.io_trace_resolvers.set(cxt.io_trace_index - 1, {resolve, reject})
})
} else if(name == 'setTimeout') {
const timeout_cb = args[0]
cxt.io_trace_resolvers.set(cxt.io_trace_index - 1, {resolve: timeout_cb})
return call.value
} else {
return call.value
}
} else {
throw call.error
}
}
}
}
Object.defineProperty(method, 'name', {value: original.name})
return method
}
const patch_Date = (window) => {
const Date_original = window.Date
const Date_patched = make_patched_method(window, Date_original, 'Date', false)
window.Date = function Date(...args) {
if(args.length == 0) {
// return current Date, IO operation
if(new.target != null) {
return new Date_patched(...args)
} else {
return Date_patched(...args)
}
} else {
// pure function
if(new.target != null) {
return new Date_original(...args)
} else {
return Date_original(...args)
}
}
}
window.Date.parse = Date.parse
window.Date.now = Date.now
window.Date.UTC = Date.UTC
io_patch(window, ['Date', 'now'])
}
const patch_interval = (window, name) => {
const original = window[name]
window[name] = function(...args) {
const has_new_target = new.target != null
const {value, is_return} = patched_method_prolog(
window,
original,
this,
has_new_target,
args
)
if(is_return) {
return value
}
const cxt = window.__cxt
if(!cxt.io_trace_is_recording) {
/*
Discard io_trace. Run code without IO trace because it is not clear
how it should work with io trace
*/
abort_replay(cxt)
}
return has_new_target
? new original(...args)
: original.apply(this, args)
}
Object.defineProperty(window[name], 'name', {value: name})
}
export const apply_io_patches = (window) => {
io_patch(window, ['Math', 'random'])
io_patch(window, ['setTimeout'])
// TODO if call setTimeout and then clearTimeout, trace it and remove call of
// clearTimeout, and make only setTimeout, then it would never be called when
// replaying from trace
io_patch(window, ['clearTimeout'])
patch_interval(window, 'setInterval')
patch_interval(window, 'clearInterval')
patch_Date(window)
io_patch(window, ['fetch'])
// Check if Response is defined, for node.js
if(window.Response != null) {
const Response_methods = [
'arrayBuffer',
'blob',
'formData',
'json',
'text',
]
for(let key of Response_methods) {
io_patch(window, ['Response', 'prototype', key], true)
}
}
}

View File

@@ -1,575 +0,0 @@
import {apply_io_patches} from './record_io.js'
import {LetMultiversion} from './let_multiversion.js'
import {defineMultiversionArray, create_array, wrap_array} from './array.js'
import {create_object} from './object.js'
import {defineMultiversionSet} from './set.js'
import {defineMultiversionMap} from './map.js'
import {apply_canvas_patches} from '../canvas.js'
/*
Converts generator-returning function to promise-returning function. Allows to
have the same code both for sync and async. If we have only sync modules (no
toplevel awaits), then code executes synchronously, and if there are async
modules, then code executes asynchronoulsy, but we have syntactic niceties of
'yield', 'try', 'catch'
*/
const gen_to_promise = gen_fn => {
return (...args) => {
const gen = gen_fn(...args)
const next = result => {
if(result.done){
return result.value
} else {
if(result.value?.[Symbol.toStringTag] == 'Promise') {
return result.value.then(
value => next(gen.next(value)),
error => next(gen.throw(error)),
)
} else {
return next(gen.next(result.value))
}
}
}
return next(gen.next())
}
}
const make_promise_with_rejector = cxt => {
let rejector
const p = new Promise(r => rejector = r)
return [p, rejector]
}
export const run = gen_to_promise(function*(module_fns, cxt, io_trace) {
if(!cxt.window.__is_initialized) {
defineMultiversion(cxt.window)
apply_io_patches(cxt.window)
inject_leporello_api(cxt)
apply_canvas_patches(cxt.window)
cxt.window.__is_initialized = true
} else {
throw new Error('illegal state')
}
let calltree
const calltree_node_by_loc = new Map(
module_fns.map(({module}) => [module, new Map()])
)
const [replay_aborted_promise, io_trace_abort_replay] =
make_promise_with_rejector(cxt)
cxt = (io_trace == null || io_trace.length == 0)
// TODO group all io_trace_ properties to single object?
? {...cxt,
calltree_node_by_loc,
logs: [],
io_trace_is_recording: true,
io_trace: [],
}
: {...cxt,
calltree_node_by_loc,
logs: [],
io_trace_is_recording: false,
io_trace,
io_trace_is_replay_aborted: false,
io_trace_resolver_is_set: false,
// Map of (index in io_trace) -> resolve
io_trace_resolvers: new Map(),
io_trace_index: 0,
io_trace_abort_replay,
}
// Set current context
cxt.window.__cxt = cxt
apply_promise_patch(cxt)
for(let i = 0; i < module_fns.length; i++) {
const {module, fn} = module_fns[i]
cxt.is_entrypoint = i == module_fns.length - 1
cxt.children = null
calltree = {
toplevel: true,
module,
id: ++cxt.call_counter,
version_number: cxt.version_counter,
let_vars: {},
literals: new Map(),
}
try {
cxt.modules[module] = {}
const result = fn(
cxt,
calltree.let_vars,
calltree.literals,
calltree_node_by_loc.get(module),
__trace,
__trace_call,
__await_start,
__await_finish,
__save_ct_node_for_path,
LetMultiversion,
create_array,
create_object,
)
if(result?.[Symbol.toStringTag] == 'Promise') {
yield Promise.race([replay_aborted_promise, result])
} else {
yield result
}
calltree.ok = true
} catch(error) {
calltree.ok = false
calltree.error = error
}
calltree.children = cxt.children
calltree.last_version_number = cxt.version_counter
if(!calltree.ok) {
break
}
}
cxt.is_recording_deferred_calls = true
const _logs = cxt.logs
cxt.logs = []
cxt.children = null
remove_promise_patch(cxt)
return {
modules: cxt.modules,
calltree,
logs: _logs,
rt_cxt: cxt,
calltree_node_by_loc,
}
})
const inject_leporello_api = cxt => {
cxt.window.leporello = { storage: cxt.storage }
}
const apply_promise_patch = cxt => {
if(cxt.window.Promise.prototype.__original_then != null) {
throw new Error('illegal state')
}
const original_then = cxt.window.Promise.prototype.then
cxt.window.Promise.prototype.__original_then = cxt.window.Promise.prototype.then
cxt.window.Promise.prototype.then = function then(on_resolve, on_reject) {
if(cxt.children == null) {
cxt.children = []
}
let children_copy = cxt.children
const make_callback = (cb, ok) => typeof(cb) != 'function'
? cb
: value => {
if(this.status == null) {
this.status = ok ? {ok, value} : {ok, error: value}
}
const current = cxt.children
cxt.children = children_copy
try {
return cb(value)
} finally {
cxt.children = current
}
}
return original_then.call(
this,
make_callback(on_resolve, true),
make_callback(on_reject, false),
)
}
}
const remove_promise_patch = cxt => {
cxt.window.Promise.prototype.then = cxt.window.Promise.prototype.__original_then
delete cxt.window.Promise.prototype.__original_then
}
export const set_record_call = cxt => {
for(let i = 0; i < cxt.stack.length; i++) {
cxt.stack[i] = true
}
}
export const do_eval_expand_calltree_node = (cxt, node) => {
cxt.is_recording_deferred_calls = false
// Save call counter and set it to the value it had when executed 'fn' for
// the first time
const call_counter = cxt.call_counter
cxt.call_counter = node.fn.__location == null
// Function is native, set call_counter to node.id
? node.id
// call_counter will be incremented inside __trace and produce the same id
// as node.id
: node.id - 1
cxt.children = null
try {
with_version_number(cxt, node.version_number, () => {
if(node.is_new) {
new node.fn(...node.args)
} else {
node.fn.apply(node.context, node.args)
}
})
} catch(e) {
// do nothing. Exception was caught and recorded inside '__trace'
}
// Restore call counter
cxt.call_counter = call_counter
cxt.is_recording_deferred_calls = true
const children = cxt.children
cxt.children = null
if(node.fn.__location != null) {
// fn is hosted, it created call, this time with children
const result = children[0]
if(result.id != node.id) {
throw new Error('illegal state')
}
result.children = cxt.prev_children
result.has_more_children = false
return result
} else {
// fn is native, it did not created call, only its child did
return {...node,
children: children,
has_more_children: false,
}
}
}
const __await_start = (cxt, promise) => {
// children is an array of child calls for current function call. But it
// can be null to save one empty array allocation in case it has no child
// calls. Allocate array now, so we can have a reference to this array
// which will be used after await
if(cxt.children == null) {
cxt.children = []
}
const children_copy = cxt.children
const result = {children_copy, promise}
if(promise?.[Symbol.toStringTag] == 'Promise') {
result.promise = promise.then(
(value) => {
result.status = {ok: true, value}
// We do not return value on purpose - it will be return in
// __await_finish
},
(error) => {
result.status = {ok: false, error}
// We do not throw error on purpose
},
)
} else {
result.status = {ok: true, value: promise}
}
return result
}
const __await_finish = (__cxt, await_state) => {
__cxt.children = await_state.children_copy
if(await_state.status.ok) {
return await_state.status.value
} else {
throw await_state.status.error
}
}
const __trace = (cxt, fn, name, argscount, __location, get_closure, has_versioned_let_vars) => {
const result = (...args) => {
if(result.__closure == null) {
result.__closure = get_closure()
}
const children_copy = cxt.children
cxt.children = null
cxt.stack.push(false)
const call_id = ++cxt.call_counter
const version_number = cxt.version_counter
// populate calltree_node_by_loc only for entrypoint module
if(cxt.is_entrypoint && !cxt.skip_save_ct_node_for_path) {
let nodes_of_module = cxt.calltree_node_by_loc.get(__location.module)
if(nodes_of_module == null) {
nodes_of_module = new Map()
cxt.calltree_node_by_loc.set(__location.module, nodes_of_module)
}
if(nodes_of_module.get(__location.index) == null) {
set_record_call(cxt)
nodes_of_module.set(__location.index, call_id)
}
}
let let_vars
if(has_versioned_let_vars) {
let_vars = cxt.let_vars = {}
}
// TODO only allocate map if has literals
const literals = cxt.literals = new Map()
let ok, value, error
const is_toplevel_call_copy = cxt.is_toplevel_call
cxt.is_toplevel_call = false
try {
value = fn(...args)
ok = true
if(value?.[Symbol.toStringTag] == 'Promise') {
set_record_call(cxt)
}
return value
} catch(_error) {
ok = false
error = _error
set_record_call(cxt)
if(cxt.is_recording_deferred_calls && is_toplevel_call_copy) {
if(error instanceof cxt.window.Error) {
error.__ignore = true
}
}
throw error
} finally {
cxt.prev_children = cxt.children
const call = {
id: call_id,
version_number,
last_version_number: cxt.version_counter,
let_vars,
literals,
ok,
value,
error,
fn: result,
args: argscount == null
? args
// Do not capture unused args
: args.slice(0, argscount),
}
const should_record_call = cxt.stack.pop()
if(should_record_call) {
call.children = cxt.children
} else {
call.has_more_children = cxt.children != null && cxt.children.length != 0
}
cxt.children = children_copy
if(cxt.children == null) {
cxt.children = []
}
cxt.children.push(call)
cxt.is_toplevel_call = is_toplevel_call_copy
if(cxt.is_recording_deferred_calls && cxt.is_toplevel_call) {
if(cxt.children.length != 1) {
throw new Error('illegal state')
}
const call = cxt.children[0]
cxt.children = null
const _logs = cxt.logs
cxt.logs = []
cxt.on_deferred_call(call, cxt.calltree_changed_token, _logs)
}
}
}
Object.defineProperty(result, 'name', {value: name})
result.__location = __location
return result
}
const defineMultiversion = window => {
defineMultiversionArray(window)
defineMultiversionSet(window)
defineMultiversionMap(window)
}
const wrap_multiversion_value = (value, cxt) => {
// TODO use a WeakMap value => wrapper ???
if(value instanceof cxt.window.Set) {
if(!(value instanceof cxt.window.MultiversionSet)) {
return new cxt.window.MultiversionSet(value, cxt)
} else {
return value
}
}
if(value instanceof cxt.window.Map) {
if(!(value instanceof cxt.window.MultiversionMap)) {
return new cxt.window.MultiversionMap(value, cxt)
} else {
return value
}
}
if(value instanceof cxt.window.Array) {
if(!(value instanceof cxt.window.MultiversionArray)) {
return wrap_array(value, cxt)
} else {
return value
}
}
return value
}
const __trace_call = (cxt, fn, context, args, errormessage, is_new = false) => {
if(fn != null && fn.__location != null && !is_new) {
// Call will be traced, because tracing code is already embedded inside
// fn
return fn(...args)
}
if(typeof(fn) != 'function') {
throw new TypeError(
errormessage
+ ' is not a '
+ (is_new ? 'constructor' : 'function')
)
}
const children_copy = cxt.children
cxt.children = null
cxt.stack.push(false)
const call_id = ++cxt.call_counter
const version_number = cxt.version_counter
// TODO: other console fns
const is_log = fn == cxt.window.console.log || fn == cxt.window.console.error
if(is_log) {
set_record_call(cxt)
}
let ok, value, error
try {
if(!is_log) {
if(is_new) {
value = new fn(...args)
} else {
value = fn.apply(context, args)
}
} else {
value = undefined
}
ok = true
if(value?.[Symbol.toStringTag] == 'Promise') {
set_record_call(cxt)
}
value = wrap_multiversion_value(value, cxt)
return value
} catch(_error) {
ok = false
error = _error
set_record_call(cxt)
throw error
} finally {
cxt.prev_children = cxt.children
const call = {
id: call_id,
version_number,
last_version_number: cxt.version_counter,
ok,
value,
error,
fn,
args,
context,
is_log,
is_new,
}
if(is_log) {
cxt.logs.push(call)
}
const should_record_call = cxt.stack.pop()
if(should_record_call) {
call.children = cxt.children
} else {
call.has_more_children = cxt.children != null && cxt.children.length != 0
}
cxt.children = children_copy
if(cxt.children == null) {
cxt.children = []
}
cxt.children.push(call)
}
}
const __save_ct_node_for_path = (cxt, __calltree_node_by_loc, index, __call_id) => {
if(!cxt.is_entrypoint) {
return
}
if(cxt.skip_save_ct_node_for_path) {
return
}
if(__calltree_node_by_loc.get(index) == null) {
__calltree_node_by_loc.set(index, __call_id)
set_record_call(cxt)
}
}
let ct_expansion_id_gen = 0
export const with_version_number = (rt_cxt, version_number, action) => {
if(rt_cxt.logs == null) {
// check that argument is rt_cxt
throw new Error('illegal state')
}
if(version_number == null) {
throw new Error('illegal state')
}
if(rt_cxt.is_expanding_calltree_node) {
throw new Error('illegal state')
}
rt_cxt.is_expanding_calltree_node = true
const version_counter_copy = rt_cxt.version_counter
rt_cxt.version_counter = version_number
const ct_expansion_id = rt_cxt.ct_expansion_id
rt_cxt.ct_expansion_id = ct_expansion_id_gen++
try {
return action()
} finally {
rt_cxt.ct_expansion_id = ct_expansion_id
rt_cxt.is_expanding_calltree_node = false
rt_cxt.version_counter = version_counter_copy
}
}

View File

@@ -1,44 +0,0 @@
import {Multiversion, wrap_methods, rollback_if_needed} from './multiversion.js'
export const defineMultiversionSet = window => {
// We declare class in such a weird name to have its displayed name to be
// exactly 'Set'
window.MultiversionSet = class Set extends window.Set {
constructor(initial, cxt) {
super()
this.multiversion = new Multiversion(cxt)
this.initial = new globalThis.Set(initial)
this.redo_log = []
this.apply_initial()
}
apply_initial() {
super.clear()
for (const item of this.initial) {
super.add(item)
}
}
get size() {
rollback_if_needed(this)
return super.size
}
}
wrap_methods(
window.MultiversionSet,
// all methods
[
'has', 'add', 'delete', 'clear', 'entries', 'forEach', 'values', 'keys',
Symbol.iterator,
],
// mutation methods
['add', 'delete', 'clear'],
)
}

View File

@@ -1,74 +0,0 @@
const PROJECT_ID = 'leporello-js'
const URL_BASE = `https://firebasestorage.googleapis.com/v0/b/${PROJECT_ID}.appspot.com/o/`
// see https://stackoverflow.com/a/48161723/795038
async function sha256(message) {
// encode as UTF-8
const msgBuffer = new TextEncoder().encode(message);
// hash the message
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
// convert ArrayBuffer to Array
const hashArray = Array.from(new Uint8Array(hashBuffer));
// convert bytes to hex string
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return hashHex;
}
async function upload_share(text) {
const id = (await sha256(text))
// Truncate to 20 bytes, like in git
.slice(0, 40)
const blob = new Blob([text], { type: 'text/plain' })
const formData = new FormData()
formData.append('file', blob)
const response = await fetch(URL_BASE + id, {
method: 'POST',
body: formData
})
if(!response.ok) {
const json = await response.json()
const message = json?.error?.message
throw new Error('Failed to upload: ' + message)
}
return id
}
async function download_share(id) {
const response = await fetch(URL_BASE + id + '?alt=media')
if(!response.ok) {
const json = await response.json()
const message = json?.error?.message
throw new Error('Failed to fetch: ' + message)
}
return response.text()
}
export async function get_share() {
const params = new URLSearchParams(window.location.search)
const share_id = params.get('share_id')
if(share_id == null) {
return null
}
const shared_code = localStorage['share_' + share_id]
if(shared_code != null) {
return shared_code
}
try {
return await download_share(share_id)
} catch(e) {
alert(e.message)
return null
}
}
export async function save_share(text) {
const share_id = await upload_share(text)
const nextURL = new URL(window.location)
nextURL.searchParams.set('share_id', share_id)
history.replaceState(null, null, nextURL.href)
}

View File

@@ -1,118 +0,0 @@
export const findLast = new Function('arr', 'pred', `
for(let i = arr.length - 1; i >= 0; i--) {
if(pred(arr[i])) {
return arr[i]
}
}
`)
export const set_push = (x,y) => new Set([...x, y])
export const set_union = (x,y) => new Set([...x, ...y])
export const set_is_eq = (a, b) =>
a.size === b.size && [...a].every(value => b.has(value))
export const set_diff = (x,y) => {
return new Set([...x].filter(el => !y.has(el)))
}
export const map_object = (obj, mapper) => Object.fromEntries(
Object.entries(obj).map(([k, v]) => [k, mapper(k,v)])
)
export const filter_object = (obj, pred) => Object.fromEntries(
Object.entries(obj).filter(([k, v]) => pred(k,v))
)
// https://bit.cloud/ramda/ramda/map-accum/~code
export const map_accum = new Function('fn', 'acc', 'arr', `
let idx = 0;
const len = arr.length;
const result = [];
let tuple = [acc];
while (idx < len) {
tuple = fn(tuple[0], arr[idx], idx);
result[idx] = tuple[1];
idx += 1;
}
return [tuple[0], result];
`)
export const map_find = (arr, mapper) => arr.reduce(
(result, curr, i) => result ?? mapper(curr, i),
null
)
export const stringify = val => JSON.stringify(val, null, 2)
export const zip = (x,y) => {
if(x.length != y.length){
throw new Error('zipped arrays must have same length')
} else {
return x.map((el, i) => [el, y[i]])
}
}
export const uniq = arr => [...new Set(arr)]
export const uniq_by = (arr, mapper) => [
...new Map(
arr.map(e => [mapper(e), e])
)
.values()
]
export const collect_nodes_with_parents = new Function('node', 'pred', `
const result = []
const do_collect = (node, parent) => {
if(node.children != null) {
for(let c of node.children) {
do_collect(c, node)
}
}
if(pred(node)) {
result.push({node, parent})
}
}
do_collect(node, null)
return result
`)
// TODO remove
/*
function object_diff(a,b){
function do_object_diff(a,b, context=[]) {
if(a == b){
return
}
if(a == null && b == null){
return
}
if(typeof(a) != 'object' || typeof(b) != 'object'){
throw new Error(`not an object ${a} ${b}`)
}
for(let key in a) {
if(b[key] == null) {
throw new Error(`missing ${key} in right object ${context.join('.')}`)
}
}
for(let key in b) {
if(a[key] == null) {
throw new Error(`missing ${key} in left object ${context.join('.')}`)
}
do_object_diff(a[key], b[key], context.concat([key]))
}
}
try {
do_object_diff(a,b)
} catch(e){
return e.message
}
}
*/

View File

@@ -1,201 +0,0 @@
// We test both for Object and globalThis.app_window.Object because objects may
// come both from app_window and current window (where they are created in
// metacircular interpreter
const has_custom_toString = object =>
typeof(object.toString) == 'function'
&& object.toString != globalThis.app_window.Object.prototype.toString
&& object.toString != Object.prototype.toString
const isError = object =>
object instanceof Error
||
object instanceof globalThis.app_window.Error
const isPromise = object => object?.[Symbol.toStringTag] == 'Promise'
// Workaround try/catch is not implemented currently
const toJSON_safe = new Function('object', `
try {
return object.toJSON()
} catch(e) {
return object
}
`)
export const displayed_entries = object => {
if(object == null || typeof(object) != 'object') {
return []
} else if((object[Symbol.toStringTag]) == 'Module') {
return Object.entries(object)
} else if(isPromise(object)) {
if(object.status == null) {
return []
}
return displayed_entries(
object.status.ok ? object.status.value : object.status.error
)
} else if(Array.isArray(object)) {
return object.map((v, i) => [i, v])
} else if(object[Symbol.toStringTag] == 'Set') {
// TODO display set as list without keys as indexes, because Set in JS are
// not ordered and it would be incorrect to imply ordering
return [...object.values()].map((entry, i) => [i, entry])
} else if(object[Symbol.toStringTag] == 'Map') {
return [...object.entries()]
} else if(typeof(object.toJSON) == 'function') {
const result = toJSON_safe(object)
if(result == object) {
// avoid infinite recursion when toJSON returns itself
return Object.entries(object)
} else {
return displayed_entries(result)
}
} else {
return Object.entries(object)
}
}
export const is_expandable = v =>
isPromise(v)
? (
v.status != null
&& is_expandable(v.status.ok ? v.status.value : v.status.error)
)
: (
typeof(v) == 'object'
&& v != null
&& displayed_entries(v).length != 0
)
export const stringify_for_header_object = v => {
if(displayed_entries(v).length == 0) {
return '{}'
} else {
return '{…}'
}
}
export const stringify_for_header = (v, no_toJSON = false) => {
const type = typeof(v)
if(v === null) {
return 'null'
} else if(v === undefined) {
return 'undefined'
} else if(type == 'function') {
// TODO clickable link, 'fn', cursive
return 'fn ' + v.name
} else if(type == 'string') {
return JSON.stringify(v)
} else if(type == 'object') {
if((v[Symbol.toStringTag]) == 'Module') {
// protect against lodash module contains toJSON function
return stringify_for_header_object(v)
} else if (isPromise(v)) {
if(v.status == null) {
return `Promise<pending>`
} else {
if(v.status.ok) {
return `Promise<fulfilled: ${stringify_for_header(v.status.value)}>`
} else {
return `Promise<rejected: ${stringify_for_header(v.status.error)}>`
}
}
} else if(isError(v)) {
return v.toString()
} else if(Array.isArray(v)) {
if(v.length == 0) {
return '[]'
} else {
return '[…]'
}
} else if(typeof(v.toJSON) == 'function' && !no_toJSON) {
const json = toJSON_safe(v)
if(json == v) {
// prevent infinite recursion
return stringify_for_header(json, true)
} else {
return stringify_for_header(json)
}
} else if(has_custom_toString(v)) {
return v.toString()
} else {
return stringify_for_header_object(v)
}
} else {
return v.toString()
}
}
export const short_header = value =>
Array.isArray(value)
? 'Array(' + value.length + ')'
: ''
const header_object = object => {
const prefix =
(object.constructor?.name == null || object.constructor?.name == 'Object')
? ''
: object.constructor.name + ' '
const inner = displayed_entries(object)
.map(([k,v]) => {
const value = stringify_for_header(v)
return `${k}: ${value}`
})
.join(', ')
return `${prefix}{${inner}}`
}
export const header = (object, no_toJSON = false) => {
const type = typeof(object)
if(object === null) {
return 'null'
} else if(object === undefined) {
return 'undefined'
} else if(type == 'function') {
// TODO clickable link, 'fn', cursive
return 'fn ' + object.name
} else if(type == 'string') {
return JSON.stringify(object)
} else if(type == 'object') {
if((object[Symbol.toStringTag]) == 'Module') {
// protect against lodash module contains toJSON function
return header_object(object)
} else if(isPromise(object)) {
if(object.status == null) {
return `Promise<pending>`
} else {
if(object.status.ok) {
return `Promise<fulfilled: ${header(object.status.value)}>`
} else {
return `Promise<rejected: ${header(object.status.error)}>`
}
}
} else if(isError(object)) {
return object.toString()
} else if(Array.isArray(object)) {
return '['
+ object
.map(stringify_for_header)
.join(', ')
+ ']'
} else if(typeof(object.toJSON) == 'function' && !no_toJSON) {
const json = toJSON_safe(object)
if(json == object) {
// prevent infinite recursion
return header(object, true)
} else {
return header(json)
}
} else if(has_custom_toString(object)) {
return object.toString()
} else {
return header_object(object)
}
} else {
return object.toString()
}
}

View File

@@ -1,6 +0,0 @@
import {tests} from './test.js'
// external
import {run} from './utils.js'
await run(tests)

View File

@@ -1,54 +0,0 @@
/*
For node.js tests
It forces node.js to load Response (which is loaded lazily)
Without this, `Response` loading code would be executed in record_io.js and
break test by calling `now()`
*/
globalThis.Response
if(globalThis.process != null) {
globalThis.NodeVM = await import('node:vm')
}
let iframe
export function create_app_window() {
if(globalThis.process != null) {
// We are in node.js
// `NodeVM` was preloaded earlier
const context = globalThis.NodeVM.createContext({
process,
// for some reason URL is not available inside VM
URL,
console,
setTimeout,
// break fetch because we dont want it to be accidentally called in unit test
fetch: () => {
console.error('Error! fetch called')
},
})
const get_global_object = globalThis.NodeVM.compileFunction(
'return this',
[], // args
{parsingContext: context}
)
return get_global_object()
} else {
// We are in browser
if(iframe != null) {
globalThis.document.body.removeChild(iframe)
}
iframe = globalThis.document.createElement('iframe')
document.body.appendChild(iframe)
return iframe.contentWindow
}
}

View File

@@ -1,108 +0,0 @@
/*
Loads Leporello then runs tests inside Leporello.
Benchmarks how fast test suite is executed inside leporello
*/
import fs from 'fs'
import * as pathlib from 'path'
import {COMMANDS} from '../src/cmd.js'
import {root_calltree_node} from '../src/calltree.js'
import { assert_equal, test_initial_state, } from './utils.js'
import {tests} from './test.js'
// Should work same as src/filesystem.js:load_dir
const load_dir = path => {
const kind = fs.statSync(path).isDirectory() ? 'directory' : 'file'
const props = {
path,
name: pathlib.basename(path),
kind,
}
if(kind == 'file') {
return {...props, contents: fs.readFileSync(path, 'utf8')}
} else {
return {
...props,
children: fs.readdirSync(path)
.filter(f => !f.startsWith('.'))
.map(file =>
load_dir(pathlib.join(path, file))
)
}
}
}
// Convert path to modules relative to '.' into path relative to this file
const adjust_path = path => {
return pathlib.join(
pathlib.relative(
pathlib.dirname(import.meta.url.replace('file://', '')),
pathlib.resolve('.'),
),
path
)
}
const load_external_modules = async state => {
const urls = state.loading_external_imports_state.external_imports
const results = await Promise.all(
urls.map(u => import(adjust_path(u)))
)
return Object.fromEntries(
results.map((module, i) => (
[
urls[i],
{
ok: true,
module,
}
]
))
)
}
const dir = load_dir('.')
console.time('run')
const i = test_initial_state(
{}, // files
undefined,
{
project_dir: dir,
entrypoint: 'test/run.js',
}
)
if(!i.parse_result.ok) {
console.error('Parse errors:', i.parse_result.problems)
i.parse_result.problems.forEach(p => {
if(p.index != null) {
console.error(p.module + ': ' + p.message + ' at ' + i.files[p.module].slice(p.index, p.index + 80))
}
})
throw new Error('parse error')
}
assert_equal(i.loading_external_imports_state != null, true)
const external_imports = await load_external_modules(i)
const loaded = COMMANDS.external_imports_loaded(i, i, external_imports)
assert_equal(loaded.eval_modules_state != null, true)
const s = loaded.eval_modules_state
const result = await s.promise
const state = COMMANDS.eval_modules_finished(loaded, loaded, result, s.node, s.toplevel)
const root = root_calltree_node(state)
const run = root.children[0]
if(!root_calltree_node(state).ok) {
console.error(root_calltree_node(state).error)
}
assert_equal(root_calltree_node(state).ok, true)
// Assert that run children are tests
assert_equal(run.children.length, tests.length)
console.timeEnd('run')

File diff suppressed because it is too large Load Diff

View File

@@ -1,323 +0,0 @@
import {find_error_origin_node} from '../src/ast_utils.js'
import {parse, print_debug_node, load_modules} from '../src/parse_js.js'
import {active_frame, pp_calltree, version_number_symbol} from '../src/calltree.js'
import {COMMANDS} from '../src/cmd.js'
// external
import {create_app_window} from './run_utils.js'
// external
import {with_version_number} from '../src/runtime/runtime.js'
Object.assign(globalThis,
{
// for convenience, to type just `log` instead of `console.log`
log: console.log,
}
)
export const do_parse = code => parse(
code,
new Set(Object.getOwnPropertyNames(globalThis))
)
export const parse_modules = (entry, modules) =>
load_modules(
entry,
module_name => modules[module_name],
{},
new Set(Object.getOwnPropertyNames(globalThis.app_window))
)
export const assert_code_evals_to = (codestring, expected) => {
const s = test_initial_state(codestring)
if(!s.parse_result.ok) {
console.error('parse problems', s.parse_result.problems)
throw new Error('parse failed')
}
const frame = active_frame(s)
const result = frame.children.at(-1).result
assert_equal(result.ok, true)
assert_equal(result.value, expected)
return frame
}
export const assert_code_error = (codestring, error) => {
const state = test_initial_state(codestring)
const frame = active_frame(state)
assert_equal(frame.result.ok, false)
assert_equal(find_error_origin_node(frame).result.error, error)
}
export const assert_code_evals_to_async = async (codestring, expected) => {
const s = await test_initial_state_async(codestring)
const frame = active_frame(s)
const result = frame.children.at(-1).result
assert_equal(result.ok, true)
assert_equal(result.value, expected)
}
export const assert_code_error_async = async (codestring, error) => {
const s = await test_initial_state_async(codestring)
const frame = active_frame(s)
const result = frame.children[frame.children.length - 1].result
assert_equal(result.ok, false)
assert_equal(result.error, error)
}
function patch_app_window(app_window_patches) {
Object.entries(app_window_patches).forEach(([path, value]) => {
let obj = globalThis.app_window
const path_arr = path.split('.')
path_arr.forEach((el, i) => {
if(i == path_arr.length - 1) {
return
}
obj = obj[el]
})
const prop = path_arr.at(-1)
obj[prop] = value
})
}
export const run_code = (state, app_window_patches) => {
globalThis.app_window = create_app_window()
if(app_window_patches != null) {
patch_app_window(app_window_patches)
}
return COMMANDS.run_code(
state,
state.globals ?? new Set(Object.getOwnPropertyNames(globalThis.app_window))
)
}
export const test_initial_state = (code, cursor_pos, options = {}) => {
if(cursor_pos < 0) {
throw new Error('illegal cursor_pos')
}
if(typeof(options) != 'object') {
throw new Error('illegal state')
}
const {
current_module,
project_dir,
on_deferred_call,
app_window_patches,
} = options
const entrypoint = options.entrypoint ?? ''
return run_code(
COMMANDS.get_initial_state(
{
files: typeof(code) == 'object' ? code : { '' : code},
project_dir,
on_deferred_call,
},
{
entrypoint,
current_module: current_module ?? '',
},
cursor_pos
),
app_window_patches,
)
}
const wait_for_result = async state => {
assert_equal(state.eval_modules_state != null, true)
const result = await state.eval_modules_state.promise
return COMMANDS.eval_modules_finished(
state,
state,
result,
)
}
export const test_initial_state_async = async (code, ...args) => {
const s = test_initial_state(code, ...args)
return wait_for_result(s)
}
export const input = (s, code, index, options = {}) => {
if(typeof(options) != 'object') {
throw new Error('illegal state')
}
const {state, effects} = COMMANDS.input(s, code, index)
const nextstate = run_code(state, options.app_window_patches)
if(nextstate.rt_cxt?.io_trace_is_replay_aborted) {
const with_clear_trace = run_code(
COMMANDS.clear_io_trace(nextstate),
options.app_window_patches
)
return { state: with_clear_trace, effects }
} else {
return { state: nextstate, effects }
}
}
export const input_async = async (s, code, index, options) => {
const after_input = input(s, code, index, options).state
const state = await wait_for_result(after_input)
if(state.rt_cxt?.io_trace_is_replay_aborted) {
return wait_for_result(
run_code(COMMANDS.clear_io_trace(state), options.app_window_patches)
)
} else {
return state
}
}
export const test_deferred_calls_state = (code, index) => {
const {get_deferred_call, on_deferred_call} = (new Function(`
let args
return {
get_deferred_call() {
return args
},
on_deferred_call(..._args) {
args = _args
}
}
`))()
const state = test_initial_state(code, index, { on_deferred_call })
return {
state,
get_deferred_call,
on_deferred_call: state => COMMANDS.on_deferred_call(state, ...get_deferred_call())
}
}
export const stringify = val =>
JSON.stringify(val, (key, value) => {
if(value?.[Symbol.toStringTag] == 'Set'){
return [...value]
} else if(value?.[Symbol.toStringTag] == 'Map'){
return Object.fromEntries([...value.entries()])
} else if(value instanceof Error) {
return {message: value.message}
} else {
return value
}
}, 2)
export const assert_equal = (exp, actual) => {
if(typeof(exp) == 'object' && typeof(actual) == 'object'){
const exp_json = stringify(exp)
const act_json = stringify(actual)
if(exp_json != act_json){
throw new Error(`FAIL: ${exp_json} != ${act_json}`)
}
} else {
if(exp != actual){
throw new Error(`FAIL: ${exp} != ${actual}`)
}
}
}
export const print_debug_ct_node = node => {
const do_print = node => {
const {id, fn, ok, value, error, args, has_more_children} = node
const res = {id, fn: fn?.name, ok, value, error, args, has_more_children}
if(node.children == null) {
return res
} else {
const next_children = node.children.map(do_print)
return {...res, children: next_children}
}
}
return stringify(do_print(node))
}
export const assert_versioned_value = (state, versioned, expected) => {
const version_number = versioned[version_number_symbol] ?? versioned.version_number
if(version_number == null) {
throw new Error('illegal state')
}
return with_version_number(state.rt_cxt, version_number, () =>
assert_equal(versioned.value, expected)
)
}
export const assert_value_explorer = (state, expected) =>
assert_versioned_value(state, state.value_explorer.result, expected)
export const assert_selection = (state, expected) =>
assert_versioned_value(state, state.selection_state.node.result, expected)
export const test = (message, test, only = false) => {
return {
message,
test: Object.defineProperty(test, 'name', {value: message}),
only,
}
}
export const test_only = (message, t) => test(message, t, true)
// Create `run` function like this because we do not want its internals to be
// present in calltree
export const run = new Function('create_app_window', `
return function run(tests) {
// create app window for simple tests, that do not use 'test_initial_state'
globalThis.app_window = create_app_window()
// Runs test, return failure or null if not failed
const run_test = t => {
return Promise.resolve().then(t.test)
.then(() => null)
.catch(e => {
if(globalThis.process != null) {
// In node.js runner, fail fast
console.error('Failed: ' + t.message)
throw e
} else {
return e
}
})
}
// If not run in node, then dont apply filter
const filter = globalThis.process && globalThis.process.argv[2]
if(filter == null) {
const only = tests.find(t => t.only)
const tests_to_run = only == null ? tests : [only]
// Exec each test. After all tests are done, we rethrow first error if
// any. So we will mark root calltree node if one of tests failed
return tests_to_run.reduce(
(failureP, t) =>
Promise.resolve(failureP).then(failure =>
run_test(t).then(next_failure => failure ?? next_failure)
)
,
null
).then(failure => {
if(failure != null) {
throw failure
} else {
if(globalThis.process != null) {
console.log('Ok')
}
}
})
} else {
const test = tests.find(t => t.message.includes(filter))
if(test == null) {
throw new Error('test not found')
} else {
return run_test(test).then(() => {
if(globalThis.process != null) {
console.log('Ok')
}
})
}
}
}
`)(create_app_window)