mirror of
https://github.com/leporello-js/leporello-js
synced 2026-01-13 04:54:30 -08:00
deploy: leporello-js/app@4dc1f7d21a
This commit is contained in:
@@ -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
3
.github/FUNDING.yml
vendored
@@ -1,3 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [leporello-js]
|
||||
44
.github/workflows/static.yml
vendored
44
.github/workflows/static.yml
vendored
@@ -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
|
||||
25
.github/workflows/test.yml
vendored
25
.github/workflows/test.yml
vendored
@@ -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
2
.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
README.html
|
||||
.DS_Store
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
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).
|
||||
|
||||
|
||||
35
docs/examples/domevents.js
Normal file
35
docs/examples/domevents.js
Normal 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(', ')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
66
index.html
66
index.html
@@ -5,8 +5,6 @@
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
||||
<title>Leporello.js</title>
|
||||
|
||||
<!--PRELOADS_PLACEHOLDER-->
|
||||
|
||||
<script src='ace/ace.js'></script>
|
||||
<script src='ace/keybinding-vim.js'></script>
|
||||
<script src='ace/ext-language_tools.js'></script>
|
||||
@@ -18,6 +16,8 @@
|
||||
:root {
|
||||
--shadow_color: rgb(171 200 214);
|
||||
--active_color: rgb(173, 228, 253);
|
||||
--error-color: #ff000024;
|
||||
--warn-color: #fff6d5;
|
||||
}
|
||||
|
||||
html, body, .app {
|
||||
@@ -28,6 +28,25 @@
|
||||
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 {
|
||||
/* same as ace editor */
|
||||
font-family: Monaco, Menlo, "Ubuntu Mono", Consolas, source-code-pro, monospace;
|
||||
@@ -81,6 +100,7 @@
|
||||
.selection {
|
||||
position: absolute;
|
||||
background-color: #ff00ff;
|
||||
z-index: 1; /* make it on top of evaluated_ok and evaluated_error */
|
||||
}
|
||||
|
||||
.evaluated_ok {
|
||||
@@ -89,7 +109,7 @@
|
||||
}
|
||||
.evaluated_error {
|
||||
position: absolute;
|
||||
background-color: #ff000024;
|
||||
background-color: var(--error-color);
|
||||
}
|
||||
.error-code {
|
||||
/*
|
||||
@@ -157,7 +177,19 @@
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -166,12 +198,25 @@
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.callnode .active {
|
||||
background-color: var(--active_color);
|
||||
}
|
||||
.call_el {
|
||||
/*
|
||||
Make active callnode background start from the left of the calltree
|
||||
view
|
||||
*/
|
||||
margin-left: -1000vw;
|
||||
padding-left: 1000vw;
|
||||
width: 100%;
|
||||
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
}
|
||||
@@ -179,6 +224,7 @@
|
||||
padding-left: 5px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.call_header {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -187,7 +233,6 @@
|
||||
}
|
||||
.call_header.error.native {
|
||||
color: red;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.call_header.native {
|
||||
font-style: italic;
|
||||
@@ -318,9 +363,15 @@
|
||||
}
|
||||
|
||||
.value_explorer_header {
|
||||
display: inline-block;
|
||||
padding-right: 1em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.value_explorer_header .expand_icon {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.value_explorer_header.active {
|
||||
background-color: rgb(148, 227, 191);
|
||||
}
|
||||
@@ -340,6 +391,11 @@
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.statusbar .spinner {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.status, .current_file {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"type" : "module"
|
||||
}
|
||||
@@ -50,7 +50,7 @@ const serve_response_from_dir = async event => {
|
||||
let file
|
||||
|
||||
if(path == '__leporello_blank.html') {
|
||||
file = ''
|
||||
file = '<!doctype html>'
|
||||
} else if(dir_handle != null) {
|
||||
file = await read_file(dir_handle, path)
|
||||
} else {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
208
src/ast_utils.js
208
src/ast_utils.js
@@ -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))
|
||||
}
|
||||
}
|
||||
973
src/calltree.js
973
src/calltree.js
@@ -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,
|
||||
}
|
||||
144
src/canvas.js
144
src/canvas.js
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
938
src/cmd.js
938
src/cmd.js
@@ -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,
|
||||
}
|
||||
255
src/color.js
255
src/color.js
@@ -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()
|
||||
@@ -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))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
*/
|
||||
|
||||
};
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
363
src/editor/ui.js
363
src/editor/ui.js
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
352
src/effects.js
vendored
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1589
src/eval.js
1589
src/eval.js
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
335
src/index.js
335
src/index.js
@@ -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
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// external
|
||||
import {init} from './index.js'
|
||||
|
||||
import {COMMANDS} from './cmd.js'
|
||||
|
||||
init(globalThis.document.getElementById('app'), COMMANDS)
|
||||
1888
src/parse_js.js
1888
src/parse_js.js
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
)
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
)
|
||||
|
||||
}
|
||||
74
src/share.js
74
src/share.js
@@ -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)
|
||||
}
|
||||
118
src/utils.js
118
src/utils.js
@@ -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
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import {tests} from './test.js'
|
||||
|
||||
// external
|
||||
import {run} from './utils.js'
|
||||
|
||||
await run(tests)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
5394
test/test.js
5394
test/test.js
File diff suppressed because it is too large
Load Diff
323
test/utils.js
323
test/utils.js
@@ -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)
|
||||
Reference in New Issue
Block a user