This commit is contained in:
Dmitry Vasilev
2022-09-10 02:48:13 +08:00
commit fad075ad37
45 changed files with 38501 additions and 0 deletions

1
.gitignore vendored Normal file
View File

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

117
README.md Normal file
View File

@@ -0,0 +1,117 @@
# Leporello.js
Leporello.js is live coding IDE for pure functional subset of javascript. It provides novel debugging experience
## **[Try online](https://leporello-js.github.io/leporello-js/)**
## Features
- Mutating values is not allowed
![Mutating](docs/images/mutation.png)
- All values are immutable. You create new values by applying change to old values
![Immutable](docs/images/immutable.png)
- Functional programs are trees of expressions that map values to other values,
rather than a sequence of imperative statements which update the running
state of the program. Because data is never mutated, you can jump to any
point in execution of your program
![Navigation](docs/images/nav.gif)
- and inspect any intermediate values
![Inspect](docs/images/inspect.gif)
- Expressions that were evaluated have blue background. And that were not reached
have white background.
![Background](docs/images/background.png)
- Expressions that throw errors are red
![Errors](docs/images/error.png)
- When you put cursor inside function, the first call of this function is found
![Follow cursor](docs/images/follow_cursor.gif)
- You can edit this function and immediately see result
![Live coding](docs/images/edit.gif)
- Leporello is (mostly) self-hosted, i.e. built in itself
![Self-hosted](docs/images/self-hosted.png)
## Supported javascript subset
Variables are declared by `const` declaration. `var` is not supported. `let` variables can be declared to be assigned later, for cases when value depends on condition. Example:
```
let result
if (n == 0 || n == 1) {
result = n
} else {
result = fib(n - 1) + fib(n - 2)
}
```
Currenlty only one declaration for single `const` statement is supported (TODO).
Any kind of loops are not supported. Use recursion or array functions instead.
`if` / `else` can only contain blocks, not single statements (TODO).
Functions can be declared only by arrow function syntax. `function` keyword and method definitions (like `const foo = { bar() { /* body */ } }` may be supported in future. Both concise and block body are supported.
Classes are not supported. Some sort of immutable classes may be supported in future. `this` keyword is not currently supported. `new` operator is supported for instantiating builtin classes.
`switch` statements will be supported in future.
`try`, `catch` and `finally` will be supported in future. `throw` is currently supported.
ES6 modules are suppoted. Default exports are not currently supported, only named exports. Circular module dependencies are not supported (currently they crash IDE (TODO)). Import/export aliases are not supported. Exporting `let` variables is not supported. `import.meta` is not supported.
Generators are not supported.
Async/await will be supported in future.
Destructuring is mostly supported.
Some operators are not currently supported:
- Unary negation, unary plus
- Bitwise operators
- `in`, `instanceof`
- `void`
- comma operator
Operators that are not supported by design (not pure functional):
- increment, decrement
- `delete`
## Hotkeys
See built-in Help
## Editing local files
Editing local files is possible via [File System Access API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API). Click "Allow access to local project folder" to grant access to local directory.
## Run Leporello locally
To run it locally, you need to clone repo to local folder and serve it via HTTPS protocol (HTTPS is required by [File System Access API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API)). See [How to use HTTPS for local development](https://web.dev/how-to-use-local-https/)
## Running test suite
run tests in node.js:
```
node test/run.js
```
run tests in leporello itself:
![Tests](docs/images/test.png)
- grant local folder access
- select `test/run.js` as entrypoint
## Roadmap
* Support async/await and calling impure (performing IO) functions
* Use production level JS parser, probably typescript parser (so it will be
possible to program in pure functional subset of typescript)
* Implement VSCode plugin

21816
ace/ace.js Normal file

File diff suppressed because it is too large Load Diff

8
ace/ext-searchbox.js Normal file

File diff suppressed because one or more lines are too long

5895
ace/keybinding-vim.js Normal file

File diff suppressed because it is too large Load Diff

797
ace/mode-javascript.js Normal file
View File

@@ -0,0 +1,797 @@
define("ace/mode/doc_comment_highlight_rules",["require","exports","module","ace/lib/oop","ace/mode/text_highlight_rules"], function(require, exports, module) {
"use strict";
var oop = require("../lib/oop");
var TextHighlightRules = require("./text_highlight_rules").TextHighlightRules;
var DocCommentHighlightRules = function() {
this.$rules = {
"start" : [ {
token : "comment.doc.tag",
regex : "@[\\w\\d_]+" // TODO: fix email addresses
},
DocCommentHighlightRules.getTagRule(),
{
defaultToken : "comment.doc",
caseInsensitive: true
}]
};
};
oop.inherits(DocCommentHighlightRules, TextHighlightRules);
DocCommentHighlightRules.getTagRule = function(start) {
return {
token : "comment.doc.tag.storage.type",
regex : "\\b(?:TODO|FIXME|XXX|HACK)\\b"
};
};
DocCommentHighlightRules.getStartRule = function(start) {
return {
token : "comment.doc", // doc comment
regex : "\\/\\*(?=\\*)",
next : start
};
};
DocCommentHighlightRules.getEndRule = function (start) {
return {
token : "comment.doc", // closing comment
regex : "\\*\\/",
next : start
};
};
exports.DocCommentHighlightRules = DocCommentHighlightRules;
});
define("ace/mode/javascript_highlight_rules",["require","exports","module","ace/lib/oop","ace/mode/doc_comment_highlight_rules","ace/mode/text_highlight_rules"], function(require, exports, module) {
"use strict";
var oop = require("../lib/oop");
var DocCommentHighlightRules = require("./doc_comment_highlight_rules").DocCommentHighlightRules;
var TextHighlightRules = require("./text_highlight_rules").TextHighlightRules;
var identifierRe = "[a-zA-Z\\$_\u00a1-\uffff][a-zA-Z\\d\\$_\u00a1-\uffff]*";
var JavaScriptHighlightRules = function(options) {
var keywordMapper = this.createKeywordMapper({
"variable.language":
"Array|Boolean|Date|Function|Iterator|Number|Object|RegExp|String|Proxy|" + // Constructors
"Namespace|QName|XML|XMLList|" + // E4X
"ArrayBuffer|Float32Array|Float64Array|Int16Array|Int32Array|Int8Array|" +
"Uint16Array|Uint32Array|Uint8Array|Uint8ClampedArray|" +
"Error|EvalError|InternalError|RangeError|ReferenceError|StopIteration|" + // Errors
"SyntaxError|TypeError|URIError|" +
"decodeURI|decodeURIComponent|encodeURI|encodeURIComponent|eval|isFinite|" + // Non-constructor functions
"isNaN|parseFloat|parseInt|" +
"JSON|Math|" + // Other
"this|arguments|prototype|window|document" , // Pseudo
"keyword":
"const|yield|import|get|set|async|await|" +
"break|case|catch|continue|default|delete|do|else|finally|for|function|" +
"if|in|of|instanceof|new|return|switch|throw|try|typeof|let|var|while|with|debugger|" +
"__parent__|__count__|escape|unescape|with|__proto__|" +
"class|enum|extends|super|export|implements|private|public|interface|package|protected|static",
"storage.type":
"const|let|var|function",
"constant.language":
"null|Infinity|NaN|undefined",
"support.function":
"alert",
"constant.language.boolean": "true|false"
}, "identifier");
var kwBeforeRe = "case|do|else|finally|in|instanceof|return|throw|try|typeof|yield|void";
var escapedRe = "\\\\(?:x[0-9a-fA-F]{2}|" + // hex
"u[0-9a-fA-F]{4}|" + // unicode
"u{[0-9a-fA-F]{1,6}}|" + // es6 unicode
"[0-2][0-7]{0,2}|" + // oct
"3[0-7][0-7]?|" + // oct
"[4-7][0-7]?|" + //oct
".)";
this.$rules = {
"no_regex" : [
DocCommentHighlightRules.getStartRule("doc-start"),
comments("no_regex"),
{
token : "string",
regex : "'(?=.)",
next : "qstring"
}, {
token : "string",
regex : '"(?=.)',
next : "qqstring"
}, {
token : "constant.numeric", // hexadecimal, octal and binary
regex : /0(?:[xX][0-9a-fA-F]+|[oO][0-7]+|[bB][01]+)\b/
}, {
token : "constant.numeric", // decimal integers and floats
regex : /(?:\d\d*(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+\b)?/
}, {
token : [
"storage.type", "punctuation.operator", "support.function",
"punctuation.operator", "entity.name.function", "text","keyword.operator"
],
regex : "(" + identifierRe + ")(\\.)(prototype)(\\.)(" + identifierRe +")(\\s*)(=)",
next: "function_arguments"
}, {
token : [
"storage.type", "punctuation.operator", "entity.name.function", "text",
"keyword.operator", "text", "storage.type", "text", "paren.lparen"
],
regex : "(" + identifierRe + ")(\\.)(" + identifierRe +")(\\s*)(=)(\\s*)(function)(\\s*)(\\()",
next: "function_arguments"
}, {
token : [
"entity.name.function", "text", "keyword.operator", "text", "storage.type",
"text", "paren.lparen"
],
regex : "(" + identifierRe +")(\\s*)(=)(\\s*)(function)(\\s*)(\\()",
next: "function_arguments"
}, {
token : [
"storage.type", "punctuation.operator", "entity.name.function", "text",
"keyword.operator", "text",
"storage.type", "text", "entity.name.function", "text", "paren.lparen"
],
regex : "(" + identifierRe + ")(\\.)(" + identifierRe +")(\\s*)(=)(\\s*)(function)(\\s+)(\\w+)(\\s*)(\\()",
next: "function_arguments"
}, {
token : [
"storage.type", "text", "entity.name.function", "text", "paren.lparen"
],
regex : "(function)(\\s+)(" + identifierRe + ")(\\s*)(\\()",
next: "function_arguments"
}, {
token : [
"entity.name.function", "text", "punctuation.operator",
"text", "storage.type", "text", "paren.lparen"
],
regex : "(" + identifierRe + ")(\\s*)(:)(\\s*)(function)(\\s*)(\\()",
next: "function_arguments"
}, {
token : [
"text", "text", "storage.type", "text", "paren.lparen"
],
regex : "(:)(\\s*)(function)(\\s*)(\\()",
next: "function_arguments"
}, {
token : "keyword",
regex : "from(?=\\s*('|\"))"
}, {
token : "keyword",
regex : "(?:" + kwBeforeRe + ")\\b",
next : "start"
}, {
token : ["support.constant"],
regex : /that\b/
}, {
token : ["storage.type", "punctuation.operator", "support.function.firebug"],
regex : /(console)(\.)(warn|info|log|error|time|trace|timeEnd|assert)\b/
}, {
token : keywordMapper,
regex : identifierRe
}, {
token : "punctuation.operator",
regex : /[.](?![.])/,
next : "property"
}, {
token : "storage.type",
regex : /=>/,
next : "start"
}, {
token : "keyword.operator",
regex : /--|\+\+|\.{3}|===|==|=|!=|!==|<+=?|>+=?|!|&&|\|\||\?:|[!$%&*+\-~\/^]=?/,
next : "start"
}, {
token : "punctuation.operator",
regex : /[?:,;.]/,
next : "start"
}, {
token : "paren.lparen",
regex : /[\[({]/,
next : "start"
}, {
token : "paren.rparen",
regex : /[\])}]/
}, {
token: "comment",
regex: /^#!.*$/
}
],
property: [{
token : "text",
regex : "\\s+"
}, {
token : [
"storage.type", "punctuation.operator", "entity.name.function", "text",
"keyword.operator", "text",
"storage.type", "text", "entity.name.function", "text", "paren.lparen"
],
regex : "(" + identifierRe + ")(\\.)(" + identifierRe +")(\\s*)(=)(\\s*)(function)(?:(\\s+)(\\w+))?(\\s*)(\\()",
next: "function_arguments"
}, {
token : "punctuation.operator",
regex : /[.](?![.])/
}, {
token : "support.function",
regex : /(s(?:h(?:ift|ow(?:Mod(?:elessDialog|alDialog)|Help))|croll(?:X|By(?:Pages|Lines)?|Y|To)?|t(?:op|rike)|i(?:n|zeToContent|debar|gnText)|ort|u(?:p|b(?:str(?:ing)?)?)|pli(?:ce|t)|e(?:nd|t(?:Re(?:sizable|questHeader)|M(?:i(?:nutes|lliseconds)|onth)|Seconds|Ho(?:tKeys|urs)|Year|Cursor|Time(?:out)?|Interval|ZOptions|Date|UTC(?:M(?:i(?:nutes|lliseconds)|onth)|Seconds|Hours|Date|FullYear)|FullYear|Active)|arch)|qrt|lice|avePreferences|mall)|h(?:ome|andleEvent)|navigate|c(?:har(?:CodeAt|At)|o(?:s|n(?:cat|textual|firm)|mpile)|eil|lear(?:Timeout|Interval)?|a(?:ptureEvents|ll)|reate(?:StyleSheet|Popup|EventObject))|t(?:o(?:GMTString|S(?:tring|ource)|U(?:TCString|pperCase)|Lo(?:caleString|werCase))|est|a(?:n|int(?:Enabled)?))|i(?:s(?:NaN|Finite)|ndexOf|talics)|d(?:isableExternalCapture|ump|etachEvent)|u(?:n(?:shift|taint|escape|watch)|pdateCommands)|j(?:oin|avaEnabled)|p(?:o(?:p|w)|ush|lugins.refresh|a(?:ddings|rse(?:Int|Float)?)|r(?:int|ompt|eference))|e(?:scape|nableExternalCapture|val|lementFromPoint|x(?:p|ec(?:Script|Command)?))|valueOf|UTC|queryCommand(?:State|Indeterm|Enabled|Value)|f(?:i(?:nd|le(?:ModifiedDate|Size|CreatedDate|UpdatedDate)|xed)|o(?:nt(?:size|color)|rward)|loor|romCharCode)|watch|l(?:ink|o(?:ad|g)|astIndexOf)|a(?:sin|nchor|cos|t(?:tachEvent|ob|an(?:2)?)|pply|lert|b(?:s|ort))|r(?:ou(?:nd|teEvents)|e(?:size(?:By|To)|calc|turnValue|place|verse|l(?:oad|ease(?:Capture|Events)))|andom)|g(?:o|et(?:ResponseHeader|M(?:i(?:nutes|lliseconds)|onth)|Se(?:conds|lection)|Hours|Year|Time(?:zoneOffset)?|Da(?:y|te)|UTC(?:M(?:i(?:nutes|lliseconds)|onth)|Seconds|Hours|Da(?:y|te)|FullYear)|FullYear|A(?:ttention|llResponseHeaders)))|m(?:in|ove(?:B(?:y|elow)|To(?:Absolute)?|Above)|ergeAttributes|a(?:tch|rgins|x))|b(?:toa|ig|o(?:ld|rderWidths)|link|ack))\b(?=\()/
}, {
token : "support.function.dom",
regex : /(s(?:ub(?:stringData|mit)|plitText|e(?:t(?:NamedItem|Attribute(?:Node)?)|lect))|has(?:ChildNodes|Feature)|namedItem|c(?:l(?:ick|o(?:se|neNode))|reate(?:C(?:omment|DATASection|aption)|T(?:Head|extNode|Foot)|DocumentFragment|ProcessingInstruction|E(?:ntityReference|lement)|Attribute))|tabIndex|i(?:nsert(?:Row|Before|Cell|Data)|tem)|open|delete(?:Row|C(?:ell|aption)|T(?:Head|Foot)|Data)|focus|write(?:ln)?|a(?:dd|ppend(?:Child|Data))|re(?:set|place(?:Child|Data)|move(?:NamedItem|Child|Attribute(?:Node)?)?)|get(?:NamedItem|Element(?:sBy(?:Name|TagName|ClassName)|ById)|Attribute(?:Node)?)|blur)\b(?=\()/
}, {
token : "support.constant",
regex : /(s(?:ystemLanguage|cr(?:ipts|ollbars|een(?:X|Y|Top|Left))|t(?:yle(?:Sheets)?|atus(?:Text|bar)?)|ibling(?:Below|Above)|ource|uffixes|e(?:curity(?:Policy)?|l(?:ection|f)))|h(?:istory|ost(?:name)?|as(?:h|Focus))|y|X(?:MLDocument|SLDocument)|n(?:ext|ame(?:space(?:s|URI)|Prop))|M(?:IN_VALUE|AX_VALUE)|c(?:haracterSet|o(?:n(?:structor|trollers)|okieEnabled|lorDepth|mp(?:onents|lete))|urrent|puClass|l(?:i(?:p(?:boardData)?|entInformation)|osed|asses)|alle(?:e|r)|rypto)|t(?:o(?:olbar|p)|ext(?:Transform|Indent|Decoration|Align)|ags)|SQRT(?:1_2|2)|i(?:n(?:ner(?:Height|Width)|put)|ds|gnoreCase)|zIndex|o(?:scpu|n(?:readystatechange|Line)|uter(?:Height|Width)|p(?:sProfile|ener)|ffscreenBuffering)|NEGATIVE_INFINITY|d(?:i(?:splay|alog(?:Height|Top|Width|Left|Arguments)|rectories)|e(?:scription|fault(?:Status|Ch(?:ecked|arset)|View)))|u(?:ser(?:Profile|Language|Agent)|n(?:iqueID|defined)|pdateInterval)|_content|p(?:ixelDepth|ort|ersonalbar|kcs11|l(?:ugins|atform)|a(?:thname|dding(?:Right|Bottom|Top|Left)|rent(?:Window|Layer)?|ge(?:X(?:Offset)?|Y(?:Offset)?))|r(?:o(?:to(?:col|type)|duct(?:Sub)?|mpter)|e(?:vious|fix)))|e(?:n(?:coding|abledPlugin)|x(?:ternal|pando)|mbeds)|v(?:isibility|endor(?:Sub)?|Linkcolor)|URLUnencoded|P(?:I|OSITIVE_INFINITY)|f(?:ilename|o(?:nt(?:Size|Family|Weight)|rmName)|rame(?:s|Element)|gColor)|E|whiteSpace|l(?:i(?:stStyleType|n(?:eHeight|kColor))|o(?:ca(?:tion(?:bar)?|lName)|wsrc)|e(?:ngth|ft(?:Context)?)|a(?:st(?:M(?:odified|atch)|Index|Paren)|yer(?:s|X)|nguage))|a(?:pp(?:MinorVersion|Name|Co(?:deName|re)|Version)|vail(?:Height|Top|Width|Left)|ll|r(?:ity|guments)|Linkcolor|bove)|r(?:ight(?:Context)?|e(?:sponse(?:XML|Text)|adyState))|global|x|m(?:imeTypes|ultiline|enubar|argin(?:Right|Bottom|Top|Left))|L(?:N(?:10|2)|OG(?:10E|2E))|b(?:o(?:ttom|rder(?:Width|RightWidth|BottomWidth|Style|Color|TopWidth|LeftWidth))|ufferDepth|elow|ackground(?:Color|Image)))\b/
}, {
token : "identifier",
regex : identifierRe
}, {
regex: "",
token: "empty",
next: "no_regex"
}
],
"start": [
DocCommentHighlightRules.getStartRule("doc-start"),
comments("start"),
{
token: "string.regexp",
regex: "\\/",
next: "regex"
}, {
token : "text",
regex : "\\s+|^$",
next : "start"
}, {
token: "empty",
regex: "",
next: "no_regex"
}
],
"regex": [
{
token: "regexp.keyword.operator",
regex: "\\\\(?:u[\\da-fA-F]{4}|x[\\da-fA-F]{2}|.)"
}, {
token: "string.regexp",
regex: "/[sxngimy]*",
next: "no_regex"
}, {
token : "invalid",
regex: /\{\d+\b,?\d*\}[+*]|[+*$^?][+*]|[$^][?]|\?{3,}/
}, {
token : "constant.language.escape",
regex: /\(\?[:=!]|\)|\{\d+\b,?\d*\}|[+*]\?|[()$^+*?.]/
}, {
token : "constant.language.delimiter",
regex: /\|/
}, {
token: "constant.language.escape",
regex: /\[\^?/,
next: "regex_character_class"
}, {
token: "empty",
regex: "$",
next: "no_regex"
}, {
defaultToken: "string.regexp"
}
],
"regex_character_class": [
{
token: "regexp.charclass.keyword.operator",
regex: "\\\\(?:u[\\da-fA-F]{4}|x[\\da-fA-F]{2}|.)"
}, {
token: "constant.language.escape",
regex: "]",
next: "regex"
}, {
token: "constant.language.escape",
regex: "-"
}, {
token: "empty",
regex: "$",
next: "no_regex"
}, {
defaultToken: "string.regexp.charachterclass"
}
],
"function_arguments": [
{
token: "variable.parameter",
regex: identifierRe
}, {
token: "punctuation.operator",
regex: "[, ]+"
}, {
token: "punctuation.operator",
regex: "$"
}, {
token: "empty",
regex: "",
next: "no_regex"
}
],
"qqstring" : [
{
token : "constant.language.escape",
regex : escapedRe
}, {
token : "string",
regex : "\\\\$",
consumeLineEnd : true
}, {
token : "string",
regex : '"|$',
next : "no_regex"
}, {
defaultToken: "string"
}
],
"qstring" : [
{
token : "constant.language.escape",
regex : escapedRe
}, {
token : "string",
regex : "\\\\$",
consumeLineEnd : true
}, {
token : "string",
regex : "'|$",
next : "no_regex"
}, {
defaultToken: "string"
}
]
};
if (!options || !options.noES6) {
this.$rules.no_regex.unshift({
regex: "[{}]", onMatch: function(val, state, stack) {
this.next = val == "{" ? this.nextState : "";
if (val == "{" && stack.length) {
stack.unshift("start", state);
}
else if (val == "}" && stack.length) {
stack.shift();
this.next = stack.shift();
if (this.next.indexOf("string") != -1 || this.next.indexOf("jsx") != -1)
return "paren.quasi.end";
}
return val == "{" ? "paren.lparen" : "paren.rparen";
},
nextState: "start"
}, {
token : "string.quasi.start",
regex : /`/,
push : [{
token : "constant.language.escape",
regex : escapedRe
}, {
token : "paren.quasi.start",
regex : /\${/,
push : "start"
}, {
token : "string.quasi.end",
regex : /`/,
next : "pop"
}, {
defaultToken: "string.quasi"
}]
});
if (!options || options.jsx != false)
JSX.call(this);
}
this.embedRules(DocCommentHighlightRules, "doc-",
[ DocCommentHighlightRules.getEndRule("no_regex") ]);
this.normalizeRules();
};
oop.inherits(JavaScriptHighlightRules, TextHighlightRules);
function JSX() {
var tagRegex = identifierRe.replace("\\d", "\\d\\-");
var jsxTag = {
onMatch : function(val, state, stack) {
var offset = val.charAt(1) == "/" ? 2 : 1;
if (offset == 1) {
if (state != this.nextState)
stack.unshift(this.next, this.nextState, 0);
else
stack.unshift(this.next);
stack[2]++;
} else if (offset == 2) {
if (state == this.nextState) {
stack[1]--;
if (!stack[1] || stack[1] < 0) {
stack.shift();
stack.shift();
}
}
}
return [{
type: "meta.tag.punctuation." + (offset == 1 ? "" : "end-") + "tag-open.xml",
value: val.slice(0, offset)
}, {
type: "meta.tag.tag-name.xml",
value: val.substr(offset)
}];
},
regex : "</?" + tagRegex + "",
next: "jsxAttributes",
nextState: "jsx"
};
this.$rules.start.unshift(jsxTag);
var jsxJsRule = {
regex: "{",
token: "paren.quasi.start",
push: "start"
};
this.$rules.jsx = [
jsxJsRule,
jsxTag,
{include : "reference"},
{defaultToken: "string"}
];
this.$rules.jsxAttributes = [{
token : "meta.tag.punctuation.tag-close.xml",
regex : "/?>",
onMatch : function(value, currentState, stack) {
if (currentState == stack[0])
stack.shift();
if (value.length == 2) {
if (stack[0] == this.nextState)
stack[1]--;
if (!stack[1] || stack[1] < 0) {
stack.splice(0, 2);
}
}
this.next = stack[0] || "start";
return [{type: this.token, value: value}];
},
nextState: "jsx"
},
jsxJsRule,
comments("jsxAttributes"),
{
token : "entity.other.attribute-name.xml",
regex : tagRegex
}, {
token : "keyword.operator.attribute-equals.xml",
regex : "="
}, {
token : "text.tag-whitespace.xml",
regex : "\\s+"
}, {
token : "string.attribute-value.xml",
regex : "'",
stateName : "jsx_attr_q",
push : [
{token : "string.attribute-value.xml", regex: "'", next: "pop"},
{include : "reference"},
{defaultToken : "string.attribute-value.xml"}
]
}, {
token : "string.attribute-value.xml",
regex : '"',
stateName : "jsx_attr_qq",
push : [
{token : "string.attribute-value.xml", regex: '"', next: "pop"},
{include : "reference"},
{defaultToken : "string.attribute-value.xml"}
]
},
jsxTag
];
this.$rules.reference = [{
token : "constant.language.escape.reference.xml",
regex : "(?:&#[0-9]+;)|(?:&#x[0-9a-fA-F]+;)|(?:&[a-zA-Z0-9_:\\.-]+;)"
}];
}
function comments(next) {
return [
{
token : "comment", // multi line comment
regex : /\/\*/,
next: [
DocCommentHighlightRules.getTagRule(),
{token : "comment", regex : "\\*\\/", next : next || "pop"},
{defaultToken : "comment", caseInsensitive: true}
]
}, {
token : "comment",
regex : "\\/\\/",
next: [
DocCommentHighlightRules.getTagRule(),
{token : "comment", regex : "$|^", next : next || "pop"},
{defaultToken : "comment", caseInsensitive: true}
]
}
];
}
exports.JavaScriptHighlightRules = JavaScriptHighlightRules;
});
define("ace/mode/matching_brace_outdent",["require","exports","module","ace/range"], function(require, exports, module) {
"use strict";
var Range = require("../range").Range;
var MatchingBraceOutdent = function() {};
(function() {
this.checkOutdent = function(line, input) {
if (! /^\s+$/.test(line))
return false;
return /^\s*\}/.test(input);
};
this.autoOutdent = function(doc, row) {
var line = doc.getLine(row);
var match = line.match(/^(\s*\})/);
if (!match) return 0;
var column = match[1].length;
var openBracePos = doc.findMatchingBracket({row: row, column: column});
if (!openBracePos || openBracePos.row == row) return 0;
var indent = this.$getIndent(doc.getLine(openBracePos.row));
doc.replace(new Range(row, 0, row, column-1), indent);
};
this.$getIndent = function(line) {
return line.match(/^\s*/)[0];
};
}).call(MatchingBraceOutdent.prototype);
exports.MatchingBraceOutdent = MatchingBraceOutdent;
});
define("ace/mode/folding/cstyle",["require","exports","module","ace/lib/oop","ace/range","ace/mode/folding/fold_mode"], function(require, exports, module) {
"use strict";
var oop = require("../../lib/oop");
var Range = require("../../range").Range;
var BaseFoldMode = require("./fold_mode").FoldMode;
var FoldMode = exports.FoldMode = function(commentRegex) {
if (commentRegex) {
this.foldingStartMarker = new RegExp(
this.foldingStartMarker.source.replace(/\|[^|]*?$/, "|" + commentRegex.start)
);
this.foldingStopMarker = new RegExp(
this.foldingStopMarker.source.replace(/\|[^|]*?$/, "|" + commentRegex.end)
);
}
};
oop.inherits(FoldMode, BaseFoldMode);
(function() {
this.foldingStartMarker = /([\{\[\(])[^\}\]\)]*$|^\s*(\/\*)/;
this.foldingStopMarker = /^[^\[\{\(]*([\}\]\)])|^[\s\*]*(\*\/)/;
this.singleLineBlockCommentRe= /^\s*(\/\*).*\*\/\s*$/;
this.tripleStarBlockCommentRe = /^\s*(\/\*\*\*).*\*\/\s*$/;
this.startRegionRe = /^\s*(\/\*|\/\/)#?region\b/;
this._getFoldWidgetBase = this.getFoldWidget;
this.getFoldWidget = function(session, foldStyle, row) {
var line = session.getLine(row);
if (this.singleLineBlockCommentRe.test(line)) {
if (!this.startRegionRe.test(line) && !this.tripleStarBlockCommentRe.test(line))
return "";
}
var fw = this._getFoldWidgetBase(session, foldStyle, row);
if (!fw && this.startRegionRe.test(line))
return "start"; // lineCommentRegionStart
return fw;
};
this.getFoldWidgetRange = function(session, foldStyle, row, forceMultiline) {
var line = session.getLine(row);
if (this.startRegionRe.test(line))
return this.getCommentRegionBlock(session, line, row);
var match = line.match(this.foldingStartMarker);
if (match) {
var i = match.index;
if (match[1])
return this.openingBracketBlock(session, match[1], row, i);
var range = session.getCommentFoldRange(row, i + match[0].length, 1);
if (range && !range.isMultiLine()) {
if (forceMultiline) {
range = this.getSectionRange(session, row);
} else if (foldStyle != "all")
range = null;
}
return range;
}
if (foldStyle === "markbegin")
return;
var match = line.match(this.foldingStopMarker);
if (match) {
var i = match.index + match[0].length;
if (match[1])
return this.closingBracketBlock(session, match[1], row, i);
return session.getCommentFoldRange(row, i, -1);
}
};
this.getSectionRange = function(session, row) {
var line = session.getLine(row);
var startIndent = line.search(/\S/);
var startRow = row;
var startColumn = line.length;
row = row + 1;
var endRow = row;
var maxRow = session.getLength();
while (++row < maxRow) {
line = session.getLine(row);
var indent = line.search(/\S/);
if (indent === -1)
continue;
if (startIndent > indent)
break;
var subRange = this.getFoldWidgetRange(session, "all", row);
if (subRange) {
if (subRange.start.row <= startRow) {
break;
} else if (subRange.isMultiLine()) {
row = subRange.end.row;
} else if (startIndent == indent) {
break;
}
}
endRow = row;
}
return new Range(startRow, startColumn, endRow, session.getLine(endRow).length);
};
this.getCommentRegionBlock = function(session, line, row) {
var startColumn = line.search(/\s*$/);
var maxRow = session.getLength();
var startRow = row;
var re = /^\s*(?:\/\*|\/\/|--)#?(end)?region\b/;
var depth = 1;
while (++row < maxRow) {
line = session.getLine(row);
var m = re.exec(line);
if (!m) continue;
if (m[1]) depth--;
else depth++;
if (!depth) break;
}
var endRow = row;
if (endRow > startRow) {
return new Range(startRow, startColumn, endRow, line.length);
}
};
}).call(FoldMode.prototype);
});
define("ace/mode/javascript",["require","exports","module","ace/lib/oop","ace/mode/text","ace/mode/javascript_highlight_rules","ace/mode/matching_brace_outdent","ace/worker/worker_client","ace/mode/behaviour/cstyle","ace/mode/folding/cstyle"], function(require, exports, module) {
"use strict";
var oop = require("../lib/oop");
var TextMode = require("./text").Mode;
var JavaScriptHighlightRules = require("./javascript_highlight_rules").JavaScriptHighlightRules;
var MatchingBraceOutdent = require("./matching_brace_outdent").MatchingBraceOutdent;
var WorkerClient = require("../worker/worker_client").WorkerClient;
var CstyleBehaviour = require("./behaviour/cstyle").CstyleBehaviour;
var CStyleFoldMode = require("./folding/cstyle").FoldMode;
var Mode = function() {
this.HighlightRules = JavaScriptHighlightRules;
this.$outdent = new MatchingBraceOutdent();
this.$behaviour = new CstyleBehaviour();
this.foldingRules = new CStyleFoldMode();
};
oop.inherits(Mode, TextMode);
(function() {
this.lineCommentStart = "//";
this.blockComment = {start: "/*", end: "*/"};
this.$quotes = {'"': '"', "'": "'", "`": "`"};
this.getNextLineIndent = function(state, line, tab) {
var indent = this.$getIndent(line);
var tokenizedLine = this.getTokenizer().getLineTokens(line, state);
var tokens = tokenizedLine.tokens;
var endState = tokenizedLine.state;
if (tokens.length && tokens[tokens.length-1].type == "comment") {
return indent;
}
if (state == "start" || state == "no_regex") {
var match = line.match(/^.*(?:\bcase\b.*:|[\{\(\[])\s*$/);
if (match) {
indent += tab;
}
} else if (state == "doc-start") {
if (endState == "start" || endState == "no_regex") {
return "";
}
var match = line.match(/^\s*(\/?)\*/);
if (match) {
if (match[1]) {
indent += " ";
}
indent += "* ";
}
}
return indent;
};
this.checkOutdent = function(state, line, input) {
return this.$outdent.checkOutdent(line, input);
};
this.autoOutdent = function(state, doc, row) {
this.$outdent.autoOutdent(doc, row);
};
this.createWorker = function(session) {
var worker = new WorkerClient(["ace"], "ace/mode/javascript_worker", "JavaScriptWorker");
worker.attachToDocument(session.getDocument());
worker.on("annotate", function(results) {
session.setAnnotations(results.data);
});
worker.on("terminate", function() {
session.clearAnnotations();
});
return worker;
};
this.$id = "ace/mode/javascript";
}).call(Mode.prototype);
exports.Mode = Mode;
}); (function() {
window.require(["ace/mode/javascript"], function(m) {
if (typeof module == "object" && typeof exports == "object" && module) {
module.exports = m;
}
});
})();

BIN
docs/images/background.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
docs/images/edit.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
docs/images/error.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
docs/images/exec.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
docs/images/immutable.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
docs/images/inspect.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
docs/images/mutation.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
docs/images/nav.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
docs/images/self-hosted.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

BIN
docs/images/test.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

347
index.html Normal file
View File

@@ -0,0 +1,347 @@
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<script src='ace/ace.js'></script>
<script src='ace/keybinding-vim.js'></script>
<style>
:root {
--shadow_color: rgb(171 200 214);
}
body {
margin: 0px;
/* same as ace editor */
font-family: Monaco, Menlo, "Ubuntu Mono", Consolas, source-code-pro, monospace;
}
body::backdrop {
background-color: white;
}
.root {
height: 100%;
display: grid;
grid-template-areas:
"code eval"
"bottom bottom"
"statusbar statusbar";
grid-template-columns: 60% 40%;
grid-template-rows: 1fr 0.7fr 2.5em;
}
.root.embed_value_explorer {
grid-template-areas:
"code code"
"bottom files"
"statusbar statusbar";
grid-template-columns: 70% 30%;
}
.bottom {
display: grid;
}
.editor_container, .bottom, .eval, .files_container, .statusbar {
box-shadow: 1px 1px 3px 0px var(--shadow_color);
}
.editor_container, .eval, .bottom, .statusbar, .files_container {
margin: 8px;
}
.editor_container:focus-within,
.bottom:focus-within,
.eval:focus-within,
.files_container:focus-within,
.help_dialog {
outline: none;
box-shadow: 1px 1px 6px 3px var(--shadow_color);
}
.calltree:focus-within, .problems:focus-within {
outline: none;
}
.editor_container {
position: relative;
grid-area: code;
font-size: 16px;
}
.eval {
display: grid;
grid-area: eval;
overflow: auto;
white-space: pre;
}
.eval_content {
padding: 5px;
outline: none;
}
/* ace markers */
.selection {
position: absolute;
border-radius: 5px;
background-color: #ff00ff;
}
.evaluated_ok {
position: absolute;
background-color: rgb(225, 244, 253);
}
.evaluated_error {
position: absolute;
background-color: #ff000024;
}
.error-code {
/*
TODO: make underline like in all editors
*/
position: absolute;
border-bottom: 7px solid red;
}
/* end of ace markers */
.eval_error {
color: red;
}
/* calltree */
.bottom {
grid-area: bottom;
position: relative;
overflow: auto;
}
.calltree, .problems {
padding: 5px;
overflow: auto;
}
.entrypoint_select {
position: absolute;
right: 20px;
top: 7px;
}
.entrypoint_title {
margin-right: 0.5em;
}
.callnode {
margin-left: 1em;
}
.callnode .active {
background-color: rgb(225, 244, 253);
}
.call_header {
white-space: nowrap;
}
.call_header.error {
color: red;
}
.call_header.error.native {
color: red;
opacity: 0.5;
}
.call_header.native {
font-style: italic;
color: grey;
}
/* problems view */
.problem a {
color: red;
}
/* files */
.files_container {
overflow: auto;
grid-area: files;
display: flex;
flex-direction: column;
}
.allow_file_access {
height: 100%;
padding: 10px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
}
.allow_file_access .subtitle {
padding: 10px;
font-size: 0.8em;
}
.files {
overflow: auto;
padding: 5px;
}
.files .file {
margin-left: 1em;
}
.files > .file {
margin-left: 0em !important;
}
.files .file_title.active {
background-color: rgb(225, 244, 253);
}
.files .file_title .icon {
display: inline-block;
margin-right: 5px;
width: 1em;
}
.file_actions {
display: flex;
flex-direction: row;
justify-content: space-evenly;
padding: 5px;
background-color: rgb(225 244 253 / 80%);
}
/* value_explorer */
.embed_value_explorer_container {
height: 0px;
}
.embed_value_explorer_wrapper {
margin-left: 1em;
/* preserve wrapper from getting clicks for code line left to it */
pointer-events: none;
}
.embed_value_explorer_content {
pointer-events: initial;
white-space: pre;
max-width: fit-content;
background-color: white;
box-shadow: 1px 2px 3px -1px var(--shadow_color);
}
.embed_value_explorer_content:focus {
outline: none;
box-shadow: 1px 2px 11px 1px var(--shadow_color);
}
.embed_value_explorer_content .value_explorer_node {
margin-left: 0 !important;
}
.value_explorer_node {
margin-left: 1em;
}
.value_explorer_header.active {
background-color: rgb(148, 227, 191);
}
.value_explorer_key {
color: rgb(150, 0, 128);
font-weight: bold;
}
/* status */
/*
.request_fullscreen {
margin-left: auto;
}
*/
.statusbar {
grid-area: statusbar;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
}
.status, .current_file {
font-size: 1.5em;
}
.status {
color: red;
}
.options {
margin-left: auto;
padding: 5px;
}
.options > * {
margin: 5px;
}
.show_help, .github {
margin: 0em 0.5em;
}
.help_dialog[open] {
border: none;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 0;
width: 70%;
height: 70%;
background-color: white;
}
.help_dialog::backdrop {
background-color: rgb(225 244 253 / 80%);
}
.help {
padding: 2em;
border-spacing: 5px;
}
.help th {
padding: 0.5em;
}
.help th.key {
width: 5em;
}
.help td.key {
background-color: rgb(225, 244, 253, 0.5);
border-radius: 10px;
text-align: center;
}
</style>
<script type='module'>
// TODO remove
window.log = console.log
import {init} from './src/index.js'
window.addEventListener('load', () => {
init(document.body)
})
</script>
</head>
<body>
</body>
</html>

3
package.json Normal file
View File

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

30
service_worker.js Normal file
View File

@@ -0,0 +1,30 @@
/*
Should prevent navigator.serviceWorker.controller from being null on first load, but doesn't work for some reason.
TODO: compare with
https://googlechrome.github.io/samples/service-worker/post-message/
which seems to work on first load
self.addEventListener('install', function(event) {
//event.waitUntil(self.skipWaiting()); // Activate worker immediately
});
self.addEventListener('activate', function(event) {
event.waitUntil(self.clients.claim()); // Become available to all pages
});
*/
let data
self.addEventListener('message', async function(e) {
const msg = e.data
let reply
if(msg.type == 'SET') {
data = msg.data
reply = null
} else if(msg.type == 'GET') {
reply = data
} else {
throw new Error('unknown message type: ' + msg.type)
}
e.ports[0].postMessage(reply)
})

167
src/ast_utils.js Normal file
View File

@@ -0,0 +1,167 @@
import {uniq} 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.elements
.map(collect_destructuring_identifiers)
.flat()
} 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 => {
const imports = module.stmts
.filter(n => n.type == 'import')
.map(n =>
n.imports.map(i =>
({name: i.value, module: n.full_import_path})
)
)
.flat()
const modules = uniq(imports.map(i => i.module))
const by_module = Object.fromEntries(modules.map(m =>
[
m,
imports
.filter(i => i.module == m)
.map(i => i.name)
]
))
return by_module
}
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
}
}
}
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
)
}
export const find_error_origin_node = node =>
find_node(
node,
n => n.result != null && !n.result.ok && n.result.error != 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))
}
}

782
src/calltree.js Normal file
View File

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

661
src/cmd.js Normal file
View File

@@ -0,0 +1,661 @@
import {map_object, pick_keys} 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
} from './ast_utils.js'
import {load_modules} from './parse_js.js'
import {find_export} from './find_definitions.js'
import {eval_modules} from './eval.js'
import {
root_calltree_node, root_calltree_module, calltree_commands,
add_frame, calltree_node_loc, expand_path,
initial_calltree_node, default_expand_path, toggle_expanded, active_frame,
find_call, find_call_node, set_active_calltree_node
} from './calltree.js'
const apply_active_calltree_node = (state, index) => {
if(!state.parse_result.ok) {
return state
}
const node = find_call_node(state, index)
if(
// edit module that is not imported (maybe recursively by state.entrypoint)
node == null
||
node.type == 'do' /* toplevel AST node */
) {
const result = eval_modules(state.parse_result.modules, state.parse_result.sorted)
const next = {
...state,
calltree: result.calltree,
calltree_actions: result.calltree_actions,
}
if(node == state.parse_result.modules[root_calltree_module(next)]) {
const toplevel = root_calltree_node(next)
return add_frame(
default_expand_path(
next,
toplevel
),
toplevel,
)
} else {
const {node, state: next2} = initial_calltree_node(next)
return set_active_calltree_node(next2, null, node)
}
}
const {calltree, call, calltree_actions} = eval_modules(
state.parse_result.modules,
state.parse_result.sorted,
{index: node.index, module: state.current_module},
)
if(call == null) {
// Unreachable call
const {node, state: next} = initial_calltree_node({
...state,
calltree,
calltree_actions,
})
return set_active_calltree_node(next, null, node)
}
const next = {...state, calltree, calltree_actions }
// We cannot use `call` because `code` was not assigned to it
const active_calltree_node = find_node(root_calltree_node(next),
n => n.id == call.id
)
return add_frame(
default_expand_path(
expand_path(
next,
active_calltree_node
)
),
active_calltree_node,
)
}
const input = (state, code, index) => {
const files = {...state.files, [state.current_module]: code}
const next = apply_active_calltree_node(
apply_code({...state, files}, [state.current_module]),
index
)
const effects1 = next.current_module == ''
? {type: 'save_to_localstorage', args: ['code', code]}
: {type: 'write', args: [
next.current_module,
next.files[next.current_module],
]}
const {state: next2, effects: effects2} = do_move_cursor(next, index)
return {
state: next2,
effects: [effects1, effects2],
}
}
const apply_code = (state, dirty_files) => {
const parse_result = load_modules(state.entrypoint, module => {
if(dirty_files != null && dirty_files.includes(module)) {
return state.files[module]
}
if(state.parse_result != null) {
const result = state.parse_result.cache[module]
if(result != null) {
return result
} else {
return state.files[module]
}
} else {
return state.files[module]
}
})
return {
...state,
parse_result,
calltree: null,
// Shows that calltree is brand new and requires entire rerender
calltree_changed_token: {},
calltree_actions: null,
current_calltree_node: null,
active_calltree_node: null,
calltree_node_is_expanded: null,
frames: null,
calltree_node_by_loc: null,
// TODO keep selection_state?
selection_state: null,
}
}
export const apply_code_with_active_calltree_node = (state, index) => {
const next = apply_code(state)
return apply_active_calltree_node(next, index)
}
const can_evaluate_node = (parent, node) => {
// TODO also can evaluate in top level even if stepped into (and evaluate in
// any stack frame that was before current one)
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: 'cannot eval inside function: first step into it'}
}
return {ok: true}
}
const validate_index_action = state => {
if(!state.parse_result.ok){
return {state, effects: {type: 'set_status', args: ['invalid syntax']}}
}
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(!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 {
// TODO when collapsing, also check that node is evaluatable
// collapse
if(selection_state.node.children != null){
next_node =
selection_state.node.children.find(n =>
n.index <= index && n.index + n.length > index
)
??
// caret not inside child but in whitespace
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 {
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 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 {
...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 {...next_selection_state, ok: true, result}
}
} else {
return {...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 = selection(
state.selection_state,
active_frame(state),
is_expand,
index
)
const nextstate = {...state, selection_state}
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, index) => {
return apply_code_with_active_calltree_node(
{...state,
entrypoint,
current_module: entrypoint,
},
index
)
}
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 = find_export(node.value, state.parse_result.modules[d.module])
loc = {module: d.module, index: exp.index}
} else {
loc = {module: state.current_module, index: d.index}
}
return {
state: {...state, current_module: loc.module},
effects: {type: 'set_caret_position', args: [loc.index]}
}
}
}
}
}
const goto_problem = (state, p) => {
return {
state: {...state, current_module: p.module},
// TODO set focus after jump
effects: {type: 'set_caret_position', args: [p.index]}
}
}
// 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_value_explorer = (state, index) => {
if(state.active_calltree_node == null) {
return null
}
const frame = active_frame(state)
if(frame.type == 'function_expr' && frame.body.type != 'do') {
return {
index: frame.children[1].index + frame.children[1].length,
result: frame.children[1].result
}
}
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 + frame.children[0].length,
result: frame.children[0].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]
if(stmt.result == null) {
// statement was not evaluated
return null
}
let result
if(stmt.result.ok) {
if(['const', 'assignment'].includes(stmt.type)) {
result = stmt.children[1].result
} else if(stmt.type == 'return') {
result = stmt.children[0].result
} else if(stmt.type == 'let') {
return {
index: stmt.index + stmt.length,
result:
{
ok: true,
value: Object.fromEntries(
stmt.children.map(c =>
[c.value, c.result.value]
)
)
}
}
} else if(stmt.type == 'if'){
return null
} else if(stmt.type == 'import'){
result = {
ok: true,
value: pick_keys(
state.calltree[stmt.full_import_path].exports,
stmt.imports.map(i => i.value)
),
}
} else if (stmt.type == 'export') {
result = stmt.children[0].children[1].result
} else {
result = stmt.result
}
} else {
result = find_error_origin_node(stmt).result
}
const pos = stmt.index + stmt.length
return {index: pos, result}
}
const do_move_cursor = (state, index) => {
const value_exp = get_value_explorer(state, index)
if(value_exp == null) {
return {
state,
effects: {type: 'unembed_value_explorer', args: []}
}
} else {
return {
state,
effects:
state.current_module ==
calltree_node_loc(state.active_calltree_node).module
? {type: 'embed_value_explorer', args: [value_exp]}
: null
}
}
}
const move_cursor = (s, index) => {
if(!s.parse_result.ok){
return {state: s}
}
// Remove selection on move cursor
const state_sel_removed = {...s, selection_state: null}
const state = find_call(state_sel_removed, index)
const validate_result = validate_index_action(state)
if(validate_result != null) {
return {
state,
effects: {type: 'unembed_value_explorer', args: []}
}
}
return do_move_cursor(state, index)
}
const load_dir = (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 create_file = (state, dir, current_module) => {
return {...load_dir(state, dir), current_module}
}
export const get_initial_state = state => {
const with_files = state.project_dir == null
? state
: load_dir(state, state.project_dir)
const entrypoint = with_files.entrypoint
const current_module = with_files.current_module
const s = {
...with_files,
// If module for entrypoint or current_module does not exist, use *scratch*
entrypoint:
with_files.files[entrypoint] == null
? ''
: entrypoint,
current_module: with_files.files[current_module] == null
? ''
: current_module,
}
return apply_code_with_active_calltree_node(s, 0)
}
export const COMMANDS = {
input,
load_dir,
create_file,
step_into,
change_current_module,
change_entrypoint,
goto_definition,
goto_problem,
move_cursor,
eval_selection,
calltree: calltree_commands,
}

214
src/color.js Normal file
View File

@@ -0,0 +1,214 @@
const is_result_eq = (a,b) => a.result == null
? b.result == null
: b.result != null
&& a.result.ok == b.result.ok
&& a.result.error_origin == b.result.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}
: node.result.error == null
? {ok: false, error_origin: false}
: {ok: false, error_origin: true}
})
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?.error != null) {
return [node_to_color(node)]
}
const result = color_children(node, is_root)
return node.result != null && !node.result.ok
? result.map(c => c.result == null
? {...c, result: {ok: false, 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.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.calltree_node_by_loc?.[file] ?? {})
// node_id == null means it is unreachable, so do not color
.filter(node_id => node_id != null)
.map(node_id => state.frames[node_id].coloring)
.flat()

173
src/editor/calltree.js Normal file
View File

@@ -0,0 +1,173 @@
import {exec} from '../index.js'
import {el, stringify, fn_link, scrollIntoViewIfNeeded} from './domutils.js'
import {FLAGS} from '../feature_flags.js'
import {stringify_for_header} from './value_explorer.js'
import {find_node} from '../ast_utils.js'
import {is_expandable, root_calltree_node} from '../calltree.js'
// TODO perf - quadratic difficulty
const join = arr => arr.reduce(
(acc, el) => acc.length == 0
? [el]
: [...acc, ',', el],
[],
)
export class CallTree {
constructor(ui, container) {
this.ui = ui
this.container = container
this.container.addEventListener('keydown', (e) => {
// Do not scroll
e.preventDefault()
if(e.key == 'F1') {
this.ui.editor.focus()
}
if(e.key == 'F2') {
this.ui.editor.focus_value_explorer(this.container)
}
if(e.key == 'a') {
if(FLAGS.embed_value_explorer) {
exec('calltree.select_arguments')
} else {
// TODO make clear that arguments are shown
this.ui.eval.show_value(this.state.current_calltree_node.args)
this.ui.eval.focus_value_or_error(this.container)
}
}
if(e.key == 'r' || e.key == 'Enter') {
if(FLAGS.embed_value_explorer) {
exec('calltree.select_return_value')
} else {
// TODO make clear that return value is shown
this.ui.eval.show_value_or_error(this.state.current_calltree_node)
this.ui.eval.focus_value_or_error(this.container)
}
}
if(e.key == 'ArrowDown' || e.key == 'j'){
exec('calltree.arrow_down')
}
if(e.key == 'ArrowUp' || e.key == 'k'){
exec('calltree.arrow_up')
}
if(e.key == 'ArrowLeft' || e.key == 'h'){
exec('calltree.arrow_left')
}
if(e.key == 'ArrowRight' || e.key == 'l'){
exec('calltree.arrow_right')
}
})
}
on_click_node(id) {
exec('calltree.click', id)
}
clear_calltree(){
this.container.innerHTML = ''
this.node_to_el = new Map()
this.state = null
}
render_node(n, current_node){
const is_expanded = this.state.calltree_node_is_expanded[n.id]
const result = el('div', 'callnode',
el('div', {
'class': (n == current_node ? 'call_el active' : 'call_el'),
click: () => this.on_click_node(n.id),
},
!is_expandable(n)
? '\xa0'
: is_expanded ? '▼' : '▶',
n.toplevel
? el('span', '',
el('i', '',
'toplevel: ' + (n.module == '' ? '*scratch*' : n.module),
),
n.ok ? '' : el('span', 'call_header error', '\xa0', n.error.toString()),
)
: el('span',
'call_header '
+ (n.ok ? '' : 'error')
+ (n.fn.__location == null ? ' native' : '')
,
// TODO show `this` argument
n.fn.__location == null
? fn_link(n.fn)
: n.fn.name
,
'(' ,
...join(
n.args.map(
a => typeof(a) == 'function'
? fn_link(a)
: stringify_for_header(a)
)
),
')' ,
// TODO: show error message only where it was thrown, not every frame?
': ', (n.ok ? stringify_for_header(n.value) : n.error.toString())
),
),
(n.children == null || !is_expanded)
? null
: n.children.map(c => this.render_node(c, current_node))
)
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(state) {
this.render_active(this.state.current_calltree_node, false)
this.state = state
this.render_active(this.state.current_calltree_node, true)
scrollIntoViewIfNeeded(
this.container,
this.node_to_el.get(this.state.current_calltree_node.id).getElementsByClassName('call_el')[0]
)
}
render_expand_node(state) {
this.state = state
const current_node = this.state.current_calltree_node
const prev_dom_node = this.node_to_el.get(current_node.id)
const next = this.render_node(current_node, current_node)
prev_dom_node.parentNode.replaceChild(next, prev_dom_node)
}
// 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)
const current_node = state.current_calltree_node
this.container.appendChild(this.render_node(root, current_node))
this.render_select_node(state, root, current_node)
}
}

117
src/editor/domutils.js Normal file
View File

@@ -0,0 +1,117 @@
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) {
result.appendChild(
typeof(child) == 'string'
? document.createTextNode(child)
: child
)
}
}
if(Array.isArray(child)) {
child.forEach(append)
} else {
append(child)
}
})
return result
}
export function stringify(val){
function fn_to_str(fn){
// TODO if name is 'anonymous', then change name for code
return fn.__location == null
? `<span>${fn.name}</span>`
: `<a
href='javascript:void(0)'
data-location=${JSON.stringify(fn.__location)}
><i>fn</i> ${fn.name}</a>`
}
if(typeof(val) == 'undefined') {
return 'undefined'
} else if(typeof(val) == 'function'){
return fn_to_str(val)
} else {
return JSON.stringify(val, (key, value) => {
if(typeof(value) == 'function'){
return fn_to_str(value)
} else {
return value
}
})
}
}
export function fn_link(fn){
const str = stringify(fn)
const c = document.createElement('div')
c.innerHTML = str
return c.children[0]
}
// 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);
// Do not scroll horizontally
container.scrollLeft = 0
}
// 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);
// Do not scroll horizontally
container.scrollLeft = 0
}
/*
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);
}
*/
};

469
src/editor/editor.js Normal file
View File

@@ -0,0 +1,469 @@
import {exec, get_state} from '../index.js'
import {ValueExplorer} from './value_explorer.js'
import {el, stringify, fn_link} from './domutils.js'
import {FLAGS} from '../feature_flags.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) => {
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.markers = {}
this.sessions = {}
this.ace_editor = 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> */,
})
normalize_events(this.ace_editor, {
on_change: () => {
try {
exec('input', this.ace_editor.getValue(), this.get_caret_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.update_value_explorer_margin()
},
on_change_selection: () => {
try {
if(!this.is_change_selection_supressed) {
exec('move_cursor', this.get_caret_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 = 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))
})
}
unembed_value_explorer() {
if(this.widget != null) {
this.ace_editor.getSession().widgetManager.removeLineWidget(this.widget)
this.widget = null
}
}
update_value_explorer_margin() {
if(this.widget != null) {
this.widget.content.style.marginLeft =
(this.ace_editor.getSession().getScreenWidth() + 1) + 'ch'
}
}
embed_value_explorer({index, result: {ok, value, error}}) {
this.unembed_value_explorer()
const session = this.ace_editor.getSession()
const pos = session.doc.indexToPosition(index)
const row = pos.row
const line_height = this.ace_editor.renderer.lineHeight
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 widget before the first line. So we
// render in on the next line and apply translate
'style': `transform: translate(0px, -${line_height}px)`,
'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.widget.return_to == null) {
this.focus()
} else {
this.widget.return_to.focus()
}
// TODO select root in value explorer
}
container.addEventListener('keydown', e => {
if(e.key == 'Escape') {
escape()
}
})
if(ok) {
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)
}
},
})
exp.render(value)
} else {
content.appendChild(el('span', 'eval_error', error.toString()))
}
this.widget = {
row,
fixedWidth: true,
el: container,
content,
}
this.update_value_explorer_margin()
const LineWidgets = require("ace/line_widgets").LineWidgets;
if (!session.widgetManager) {
session.widgetManager = new LineWidgets(session);
session.widgetManager.attach(this.ace_editor);
}
session.widgetManager.addLineWidget(this.widget)
}
focus_value_explorer(return_to) {
if(FLAGS.embed_value_explorer) {
if(this.widget != null) {
this.widget.return_to = return_to
this.widget.content.focus({preventScroll: true})
}
} else {
if(get_state().selection_state != null) {
this.ui.eval.focus_value_or_error()
}
}
}
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)
const VimApi = require("ace/keyboard/vim").CodeMirror.Vim
this.ace_editor.commands.bindKey("F1", "switch_window");
VimApi._mapCommand({
keys: '<C-w>',
type: 'action',
action: 'aceCommand',
actionArgs: { name: "switch_window" }
})
this.ace_editor.commands.addCommand({
name: 'switch_window',
exec: (editor) => {
this.ui.calltree_container.focus()
}
})
this.ace_editor.commands.bindKey("F3", "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("F2", "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_caret_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_caret_position(), true)
}
})
this.ace_editor.commands.addCommand({
name: 'collapse_selection',
exec: () => {
exec('eval_selection', this.get_caret_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)
})
// 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 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_caret_position(file){
const session = file == null
? this.ace_editor.getSession()
: this.get_session(file)
// Session was not created for file
if(session == null) {
return null
}
return session.doc.positionToIndex(session.selection.getCursor())
}
set_caret_position(index){
if(index == null) {
throw new Error('illegal state')
}
const pos = this.ace_editor.session.doc.indexToPosition(index)
console.log('set caret position', index, pos)
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_caret_position()
exec('goto_definition', index)
}
for_each_session(cb) {
for(let file in this.sessions) {
cb(file, this.sessions[file])
}
}
}

67
src/editor/eval.js Normal file
View File

@@ -0,0 +1,67 @@
import {ValueExplorer} from './value_explorer.js'
import {el} from './domutils.js'
export class Eval {
constructor(ui, container) {
this.ui = ui
this.container = container
this.container.addEventListener('keydown', (e) => {
if(e.key == 'Escape') {
this.escape()
}
})
// TODO jump to fn location, view function calls
// container.addEventListener('click', jump_to_fn_location)
}
escape() {
if(this.focusedFrom == null) {
this.ui.editor.focus()
} else {
this.focusedFrom.focus()
this.focusedFrom = null
}
}
show_value(value){
this.container.innerHTML = ''
const container = el('div', {'class': 'eval_content', tabindex: 0})
this.container.appendChild(container)
const explorer = new ValueExplorer({
container,
on_escape: () => this.escape()
})
explorer.render(value)
}
show_error(error){
this.container.innerHTML = ''
this.container.appendChild(el('span', 'eval_error', error.toString()))
}
show_value_or_error({ok, value, error}){
if(ok) {
this.show_value(value)
} else {
this.show_error(error)
}
}
clear_value_or_error() {
this.container.innerHTML = ''
}
focus_value_or_error(from) {
this.focusedFrom = from
if(this.container.childElementCount != 1) {
throw new Error('illegal state')
}
this.container.children[0].focus()
}
}

164
src/editor/files.js Normal file
View File

@@ -0,0 +1,164 @@
import {el} from './domutils.js'
import {map_find} from '../utils.js'
import {load_dir, create_file} from '../filesystem.js'
import {exec, get_state} from '../index.js'
export class Files {
constructor(ui) {
this.ui = ui
this.el = el('div', 'files_container')
this.render(get_state())
}
open_directory() {
load_dir(true).then(dir => {
exec('load_dir', dir)
})
}
render(state) {
if(state.project_dir == null) {
this.el.innerHTML = ''
this.el.appendChild(
el('div', 'allow_file_access',
el('a', {
href: 'javascript:void(0)',
click: this.open_directory.bind(this),
},
`Allow access to local project folder`,
),
el('div', 'subtitle', `Your files will never leave your device`)
)
)
} else {
this.render_files(state.project_dir, state.current_module)
}
}
render_files(dir, current_module) {
const files = this.el.querySelector('.files')
const children = [
this.render_file({name: '*scratch*', path: ''}, current_module),
this.render_file(dir, current_module),
]
if(files == null) {
this.el.innerHTML = ''
this.el.appendChild(
el('div', 'file_actions',
el('a', {
href: 'javascript: void(0)',
click: this.create_file.bind(this, false),
},
'Create file'
),
el('a', {
href: 'javascript: void(0)',
click: this.create_file.bind(this, true),
}, 'Create dir'),
)
)
this.el.appendChild(
el('div', 'files',
children
)
)
} else {
// Replace to preserve scroll position
files.replaceChildren(...children)
}
}
render_file(file, current_module) {
const result = el('div', 'file',
el('div', {
'class': 'file_title' + (file.path == current_module ? ' active' : ''),
click: e => this.on_click(e, file)
},
el('span', 'icon',
file.kind == 'directory'
? '\u{1F4C1}' // folder icon
: '\xa0',
),
file.name,
),
file.children == null
? null
: file.children.map(c => this.render_file(c, current_module))
)
if(file.path == current_module) {
this.active_el = result
this.active_file = file
}
return result
}
async create_file(is_dir) {
if(this.active_file == null) {
throw new Error('no active file')
}
let name = prompt(`Enter ${is_dir ? 'directory' : 'file'} name`)
if(name == null) {
return
}
let dir
const root = get_state().project_dir
if(this.active_file.path == '' /* scratch */) {
// Create in root directory
dir = root
} else {
if(this.active_file.kind == 'directory') {
dir = this.active_file
} else {
const find_parent = (dir, parent) => {
if(dir.path == this.active_file.path) {
return parent
}
if(dir.children == null) {
return null
}
return map_find(dir.children, c => find_parent(c, dir))
}
dir = find_parent(root)
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
load_dir(false).then(dir => {
if(is_dir) {
exec('load_dir', dir)
} else {
exec('create_file', dir, path)
}
})
}
on_click(e, file) {
e.stopPropagation()
this.active_el.querySelector('.file_title').classList.remove('active')
this.active_el = e.currentTarget.parentElement
e.currentTarget.classList.add('active')
this.active_file = file
if(file.kind != 'directory') {
exec('change_current_module', file.path)
}
}
}

267
src/editor/ui.js Normal file
View File

@@ -0,0 +1,267 @@
import {exec, get_state} from '../index.js'
import {Editor} from './editor.js'
import {Files} from './files.js'
import {CallTree} from './calltree.js'
import {Eval} from './eval.js'
import {el} from './domutils.js'
import {FLAGS} from '../feature_flags.js'
export class UI {
constructor(container, state){
this.change_entrypoint = this.change_entrypoint.bind(this)
this.files = new Files(this)
container.appendChild(
(this.root = el('div',
'root ' + (FLAGS.embed_value_explorer ? 'embed_value_explorer' : ''),
this.editor_container = el('div', 'editor_container'),
FLAGS.embed_value_explorer
? null
: (this.eval_container = el('div', {class: 'eval'})),
el('div', 'bottom',
this.calltree_container = el('div', {"class": 'calltree', tabindex: 0}),
this.problems_container = el('div', {"class": 'problems', tabindex: 0}),
this.entrypoint_select = el('div', 'entrypoint_select')
),
this.files.el,
this.statusbar = el('div', 'statusbar',
this.status = el('div', 'status'),
this.current_module = el('div', 'current_module'),
/*
// Fullscreen cancelled on escape, TODO
el('a', {
"class" : 'request_fullscreen',
href: 'javascript:void(0)',
click: e => document.body.requestFullscreen(),
},
'Fullscreen'
),
*/
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'),
this.help_dialog = this.render_help(),
)
))
)
this.root.addEventListener('keydown', () => this.clear_status(), true)
this.root.addEventListener('click', () => this.clear_status(), true)
this.editor_container.addEventListener('keydown', e => {
if(
e.key.toLowerCase() == 'w' && e.ctrlKey == true
||
// We bind F1 later, this one to work from embed_value_explorer
e.key == 'F1'
){
this.calltree_container.focus()
}
})
this.calltree_container.addEventListener('keydown', e => {
if(
(e.key.toLowerCase() == 'w' && e.ctrlKey == true)
||
e.key == 'Escape'
){
this.editor.focus()
}
})
if(!FLAGS.embed_value_explorer) {
this.eval = new Eval(this, this.eval_container)
} else {
// Stub
this.eval = {
show_value_or_error(){},
clear_value_or_error(){},
focus_value_or_error(){},
}
}
this.editor = new Editor(this, this.editor_container)
this.calltree = new CallTree(this, this.calltree_container)
// TODO jump to another module
// TODO use exec
const jump_to_fn_location = (e) => {
let loc
if((loc = e.target.dataset.location) != null){
loc = JSON.parse(loc)
this.editor.set_caret_position(loc.index)
this.editor.focus()
}
}
// TODO when click in calltree, do not jump to location, navigateCallTree
// instead
this.calltree_container.addEventListener('click', jump_to_fn_location)
this.render_entrypoint_select(state)
this.render_current_module(state.current_module)
}
render_entrypoint_select(state) {
this.entrypoint_select.replaceChildren(
el('span', 'entrypoint_title', 'entrypoint'),
el('select', {
click: e => e.stopPropagation(),
change: this.change_entrypoint,
},
Object.keys(state.files).sort().map(f =>
el('option',
state.entrypoint == f
? { value: f, selected: true }
: { value: f},
f == '' ? "*scratch*" : f
)
)
)
)
}
change_entrypoint(e) {
const file = e.target.value
const index = this.editor.get_caret_position(file)
// if index is null, session was not created, and index after session
// creation will be 0
?? 0
exec('change_entrypoint', file, index)
this.editor.focus()
}
render_calltree(state) {
this.calltree_container.style = ''
this.problems_container.style = 'display: none'
this.calltree.render_calltree(state)
}
render_problems(problems) {
this.calltree_container.style = 'display: none'
this.problems_container.style = ''
this.problems_container.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_container.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 = [
['Switch between editor and call tree', 'F1 or Ctrl-w'],
['Go from call tree to editor', 'F1 or Esc'],
['Focus value explorer', 'F2'],
['Navigate value explorer', '← → ↑ ↓ or hjkl'],
['Leave value explorer', 'Esc'],
['Jump to definition', 'F3', 'gd'],
['Expand selection to eval expression', 'Ctrl-↓ or Ctrl-j'],
['Collapse selection', 'Ctrl-↑ or Ctrl-k'],
['Navigate call tree view', '← → ↑ ↓ or hjkl'],
['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'],
]
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'),
),
)
}
}

View File

@@ -0,0 +1,356 @@
// TODO large arrays/objects
// TODO maps, sets
// TODO show Errors in red
import {el, stringify, scrollIntoViewIfNeeded} from './domutils.js'
const displayed_entries = object => {
if(Array.isArray(object)) {
return object.map((v, i) => [i, v])
} else {
const result = Object.entries(object)
return (object instanceof Error)
? [['message', object.message], ...result]
: result
}
}
const is_expandable = v => typeof(v) == 'object'
&& v != null
&& displayed_entries(v).length != 0
export const stringify_for_header = v => {
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(v instanceof Error) {
return v.toString()
} else if(type == 'object') {
if(Array.isArray(v)) {
if(v.length == 0) {
return '[]'
} else {
return '[…]'
}
} else {
if(displayed_entries(v).length == 0) {
return '{}'
} else {
return '{…}'
}
}
} else if(type == 'string') {
return JSON.stringify(v)
} else {
return v.toString()
}
}
const header = object => {
if(typeof(object) == 'undefined') {
return 'undefined'
} else if(object == null) {
return 'null'
} else if(typeof(object) == 'object') {
if(object instanceof Error) {
return object.toString()
} else if(Array.isArray(object)) {
return '['
+ object
.map(stringify_for_header)
.join(', ')
+ ']'
} else {
const inner = displayed_entries(object)
.map(([k,v]) => {
const value = stringify_for_header(v)
return `${k}: ${value}`
})
.join(', ')
return `{${inner}}`
}
} else if(typeof(object) == 'function') {
// TODO clickable link, 'fn', cursive
return 'fn ' + object.name
} else if(typeof(object) == 'string') {
return JSON.stringify(object)
} else {
return object.toString()
}
return header
}
const get_path = (o, path) => {
if(path.length == 0) {
return o
} else {
const [start, ...rest] = path
return get_path(o[start], rest)
}
}
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) => {
/*
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
*/
const current_object = get_path(this.value, this.current_path)
if(e.key == 'ArrowDown' || e.key == 'j'){
// Do not scroll
e.preventDefault()
if(is_expandable(current_object) && this.is_expanded(this.current_path)) {
this.select_path(this.current_path.concat(
displayed_entries(current_object)[0][0]
))
} else {
const next = p => {
if(p.length == 0) {
return null
}
const parent = p.slice(0, p.length - 1)
const children = displayed_entries(get_path(this.value, parent))
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 = displayed_entries(get_path(this.value, parent))
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 => {
if(!is_expandable(get_path(this.value, p)) || !this.is_expanded(p)) {
return p
} else {
const children = displayed_entries(get_path(this.value, p))
.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(!is_expandable(current_object) || !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(is_expandable(current_object)) {
const is_expanded = this.is_expanded(this.current_path)
if(!is_expanded) {
this.toggle_expanded()
} else {
const children = displayed_entries(get_path(this.value, this.current_path))
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()
}
clear() {
this.container.innerHTML = ''
this.node_data = {is_expanded: true}
}
render(value) {
this.clear()
this.value = value
const path = []
this.container.appendChild(this.render_value_explorer_node(null, value, 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 key = this.current_path.length == 0
? null
: this.current_path[this.current_path.length - 1]
const value = get_path(this.value, this.current_path)
const next = this.render_value_explorer_node(key, value, 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(key, value, path, node_data) {
const is_exp = is_expandable(value)
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(value)
// Short header
: Array.isArray(value)
? 'Array(' + value.length + ')'
: ''
),
(is_exp && is_expanded)
? displayed_entries(value).map(([k,v]) => {
node_data.children[k] = {}
return this.render_value_explorer_node(k, v, [...path, k], node_data.children[k])
})
: []
)
node_data.el = result
return result
}
}

232
src/effects.js vendored Normal file
View File

@@ -0,0 +1,232 @@
import {write_file} from './filesystem.js'
import {color_file} from './color.js'
import {root_calltree_node, calltree_node_loc} from './calltree.js'
import {FLAGS} from './feature_flags.js'
const ensure_session = (ui, state, file = state.current_module) => {
ui.editor.ensure_session(file, state.files[file])
}
const clear_coloring = (ui, file) => {
ui.editor.remove_markers_of_type(file, 'evaluated_ok')
ui.editor.remove_markers_of_type(file, 'evaluated_error')
}
const render_coloring = (ui, state) => {
const file = state.current_module
clear_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) => {
ensure_session(ui, state)
ui.editor.switch_session(state.current_module)
render_parse_result(ui, state)
if(state.current_calltree_node != null) {
ui.render_calltree(state)
render_coloring(ui, state)
}
}
export const render_common_side_effects = (prev, next, command, ui) => {
if(
prev.project_dir != next.project_dir
||
prev.current_module != next.current_module
) {
ui.render_entrypoint_select(next)
ui.files.render(next)
}
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.current_module != next.current_module) {
ensure_session(ui, next)
ui.editor.unembed_value_explorer()
ui.editor.switch_session(next.current_module)
}
if(prev.parse_result != next.parse_result) {
render_parse_result(ui, next)
}
if(next.current_calltree_node == null) {
ui.calltree.clear_calltree()
ui.editor.for_each_session((file, session) => clear_coloring(ui, file))
ui.editor.unembed_value_explorer()
} else {
if(
prev.current_calltree_node == null
||
prev.calltree_changed_token != next.calltree_changed_token
) {
// Rerender entire calltree
ui.render_calltree(next)
ui.eval.clear_value_or_error()
ui.editor.for_each_session(f => clear_coloring(ui, f))
render_coloring(ui, next)
ui.editor.unembed_value_explorer()
} else {
const node_changed = next.current_calltree_node != prev.current_calltree_node
const id = next.current_calltree_node.id
const exp_changed = !!prev.calltree_node_is_expanded[id]
!=
!!next.calltree_node_is_expanded[id]
if(node_changed) {
ui.calltree.render_select_node(next)
}
if(exp_changed) {
ui.calltree.render_expand_node(next)
}
if(node_changed) {
if(!next.current_calltree_node.toplevel) {
ui.eval.show_value_or_error(next.current_calltree_node)
} else {
ui.eval.clear_value_or_error()
}
}
if(prev.calltree_node_by_loc != next.calltree_node_by_loc) {
render_coloring(ui, next)
}
}
}
// Render
/* Eval selection */
const selnode = next.selection_state?.node
if(prev.selection_state?.node != selnode) {
ui.editor.remove_markers_of_type(next.current_module, 'selection')
if(selnode != null) {
ui.editor.add_marker(
next.current_module,
'selection',
selnode.index,
selnode.index + selnode.length
)
}
}
const selresult = next.selection_state?.result
if(selresult != null && prev.selection_state?.result != selresult) {
if(FLAGS.embed_value_explorer) {
const node = next.selection_state.node
ui.editor.embed_value_explorer({
index: node.index + node.length,
result: next.selection_state.result,
})
} else {
ui.eval.show_value_or_error(next.selection_state.result)
}
}
}
export const EFFECTS = {
set_caret_position: (state, [index, with_focus], ui) => {
ui.editor.set_caret_position(index)
if(with_focus) {
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) => write_file(name, contents),
embed_value_explorer(state, [{index, result}], ui){
if(FLAGS.embed_value_explorer) {
ui.editor.embed_value_explorer({index, result})
} else {
ui.eval.show_value_or_error(result)
}
},
unembed_value_explorer(state, _, ui){
if(FLAGS.embed_value_explorer) {
ui.editor.unembed_value_explorer()
} else {
ui.eval.clear_value_or_error()
}
},
}

1224
src/eval.js Normal file

File diff suppressed because it is too large Load Diff

3
src/feature_flags.js Normal file
View File

@@ -0,0 +1,3 @@
export const FLAGS = {
embed_value_explorer: true,
}

135
src/filesystem.js Normal file
View File

@@ -0,0 +1,135 @@
// 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]);
});
}
globalThis.clear_directory_handle = () => {
send_message({type: 'SET', data: null})
window.location.reload()
}
let dir_handle
const request_directory_handle = async () => {
dir_handle = await showDirectoryPicker()
await send_message({type: 'SET', data: dir_handle})
return dir_handle
}
export const load_persisted_directory_handle = () => {
return navigator.serviceWorker.register('service_worker.js')
.then(() => navigator.serviceWorker.ready)
.then(() => send_message({type: 'GET'}))
.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_load_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_load_dir(c, path == null ? c.name : path + '/' + c.name)
)
.sort((a,b) => a.name > 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 load_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
}
}
return do_load_dir(handle, null)
}

304
src/find_definitions.js Normal file
View File

@@ -0,0 +1,304 @@
// TODO rename to analyze.js
import {set_push, set_diff, set_union, map_object, map_find, uniq} from './utils.js'
import {collect_destructuring_identifiers, collect_imports, ancestry, find_leaf} from './ast_utils.js'
// TODO get complete list of globals (borrow from eslint?)
import {globals} from './globals.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.imports.map(i => [i.value, i])
)
} else if(n.type == 'export'){
return scope_from_node(n.binding)
} else if(n.type == 'const' || n.type == 'let'){
return Object.fromEntries(
collect_destructuring_identifiers(n.name_node).map(node => [
node.value, node
])
)
} 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 {
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
*/
// TODO in same pass find already declared
export const find_definitions = (ast, scope = {}, closure_scope = {}, module_name) => {
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)) {
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 children_with_scope = ast.children.reduce(
({scope, children}, node) => ({
scope: {...scope, ...scope_from_node(node)},
children: children.concat([{node, scope}]),
})
,
{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, {...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, {...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 => ({...c, definition: {module: full_import_path}}))
} else if(ast.type == 'const') {
children = [add_trivial_definition(ast.name_node), ...ast.children.slice(1)]
} else if(ast.type == 'let') {
children = ast.name_node.map(add_trivial_definition)
} else {
children = ast.children
}
const {nodes, undeclared, closed} = map_find_definitions(children,
c => find_definitions(c, 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()}
}
}
// see https://stackoverflow.com/a/29855511
// Joins path segments. Preserves initial "/" and resolves ".." and "."
// Does not support using ".." to go above/outside the root.
// This means that join("foo", "../../bar") will not resolve to "../bar"
const join = new Function(`
// Split the inputs into a list of path commands.
var parts = [];
for (var i = 0, l = arguments.length; i < l; i++) {
parts = parts.concat(arguments[i].split("/"));
}
// Interpret the path commands to get the new resolved path.
var newParts = [];
for (i = 0, l = parts.length; i < l; i++) {
var part = parts[i];
// Remove leading and trailing slashes
// Also remove "." segments
if (!part || part === ".") continue;
// Interpret ".." to pop the last segment
if (part === "..") newParts.pop();
// Push new path segments.
else newParts.push(part);
}
// Preserve the initial slash if there was one.
if (parts[0] === "") newParts.unshift("");
// Turn back into a single string path.
return newParts.join("/") || (newParts.length ? "/" : ".");
`)
const concat_path = (base, imported) =>
base == '' ? join(imported) : join(base, '..', imported)
export const find_export = (name, module) => {
return map_find(module.stmts, n => {
if(n.type != 'export') {
return null
}
const ids = collect_destructuring_identifiers(n.binding.name_node)
return ids.find(i => i.value == name)
})
}
export const topsort_modules = (modules) => {
const sort_module_deps = (module) => {
return Object.keys(collect_imports(modules[module]))
.reduce(
(result, m) => result.concat(sort_module_deps(m)),
[]
)
.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]
,
[]
)
}
// 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.stmts
.filter(n => n.type == 'import')
.reduce(
(imports, n) => [
...imports,
...(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}
//TODO check for each import, there is export
})
// Topological sort
// For each module
// Depth-traverse deps and detect cycles
}
/*
TODO: relax, only disallow code that leads to broken target code
code analysis:
- function must have one and only one return statement in every branch
- return must be the last statement in block
- name is declared once and only once (including function args). Name can be imported once
- let must be assigned once and only once (in each branch)
- every assignment can only be to if identifier is earlier declared by let
- assignment can only be inside if statement (after let) (relax it?)
- cannot import names that are not exported from modules
- cannot return from modules (even inside toplevel if statements)
*/
export const analyze = (node, is_toplevel = true) => {
// TODO remove
return []
/*
// TODO sort by location?
if(node.type == 'do') {
let illegal_returns
if(is_toplevel) {
illegal_returns = node.stmts.filter(s => s.type == 'return')
} else {
const last = node.stmts[node.stmts.length - 1];
illegal_returns = node.stmts.filter(s => s.type == 'return' && s != last);
returns.map(node => ({
node,
message: 'illegal return statement',
}));
const last_return = last.type == 'return'
? null
: {node: last, message: 'block must end with return statement'}
// TODO recur to childs
}
} else if(node.children != null){
return node.children
.map(n => analyze(n, node.type == 'function_expr' ? false : is_toplevel))
.reduce((ps, p) => ps.concat(p), [])
} else {
// TODO
1
}
*/
}

39
src/globals.js Normal file
View File

@@ -0,0 +1,39 @@
export const globals = new Set([
'globalThis',
// TODO Promise,
// TODO Symbol
'Set',
'Map',
"Infinity",
"NaN",
"undefined",
"Function",
"Object",
"Array",
"Number",
"String",
"Boolean",
"Date",
"Math",
"RegExp",
"JSON",
"Error",
"EvalError",
"RangeError",
"ReferenceError",
"SyntaxError",
"TypeError",
"URIError",
"isNaN",
"isFinite",
"parseFloat",
"parseInt",
"eval",
"escape",
"unescape",
"decodeURI",
"decodeURIComponent",
"encodeURI",
"encodeURIComponent",
"console",
])

112
src/index.js Normal file
View File

@@ -0,0 +1,112 @@
import {COMMANDS, get_initial_state} from './cmd.js'
import {active_frame} from './calltree.js'
import {UI} from './editor/ui.js'
import {EFFECTS, render_initial_state, render_common_side_effects} from './effects.js'
import {load_dir} from './filesystem.js'
const EXAMPLE = `const fib = n =>
n == 0 || n == 1
? n
: fib(n - 1) + fib(n - 2)
fib(6)`
const read_modules = async () => {
const default_module = {'': localStorage.code || EXAMPLE}
const current = {
// TODO fix when there are no such modules anymore
current_module: localStorage.current_module ?? '',
entrypoint: localStorage.entrypoint ?? '',
}
const project_dir = await load_dir(false)
if(project_dir == null) {
// Single anonymous module
return {
...current,
files: default_module,
}
} else {
return {
...current,
project_dir,
files: default_module,
}
}
}
let ui
let state
export const init = (container) => {
// TODO err.message
window.onerror = (msg, src, lineNum, colNum, err) => {
ui.set_status(msg)
}
window.addEventListener('unhandledrejection', (event) => {
ui.set_status(event.reason)
})
read_modules().then(initial_state => {
state = get_initial_state(initial_state)
// Expose state for debugging
globalThis.__state = state
ui = new UI(container, state)
// Expose for debugging
globalThis.__ui = ui
render_initial_state(ui, state)
})
}
export const get_state = () => state
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?.parse_result == null) {
console.error('command did not return state, returned', result)
throw new Error('illegal state')
}
render_common_side_effects(state, nextstate, cmd, ui);
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)
})
}
// Expose for debugging
globalThis.__prev_state = state
globalThis.__state = nextstate
state = nextstate
}

1633
src/parse_js.js Normal file

File diff suppressed because it is too large Load Diff

48
src/reserved.js Normal file
View File

@@ -0,0 +1,48 @@
// 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',
]

95
src/utils.js Normal file
View File

@@ -0,0 +1,95 @@
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_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 pick_keys = (obj, keys) => {
return Object.fromEntries(
Object.entries(obj).filter(([k,v]) => keys.includes(k))
)
}
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)]
// 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
}
}
*/

4
test/run.js Normal file
View File

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

82
test/self_hosted_test.js Normal file
View File

@@ -0,0 +1,82 @@
import fs from 'fs'
import {load_modules} from '../src/parse_js.js'
import {eval_modules, eval_frame} from '../src/eval.js'
import {
assert_equal,
run,
stringify,
test,
} from './utils.js'
const entry = `
import {parse, load_modules} from './src/parse_js.js';
import {get_initial_state} from './src/cmd.js';
//console.time('p');
//const parsed = parse(globalThis.module_cache['./src/parse_js.js']);
//console.timeEnd('p');
//const parsed = parse('1');
const loader = module => globalThis.module_cache[module];
console.time('p2');
load_modules('src/parse_js.js', (m) => {
return loader(m)
});
console.timeEnd('p2')
//import {} from './test/test.js'
`
globalThis.module_cache = {}
const load_module = (dir, module) => {
return (globalThis.module_cache[module] = fs.readFileSync(dir + module, 'utf8'))
}
const loader = module => {
return module == ''
? entry
: load_module('./', module)
}
run([
test('self-hosted', () => {
//console.time('p0')
const parsed = load_modules('', loader)
//log('cache', Object.keys(globalThis.module_cache))
//console.log('p', parsed)
//console.timeEnd('p0')
if(!parsed.ok) {
const p = parsed.problems[0]
console.error('FAIL', p.index, p.message, p.module)
console.log(loader(p.module).slice(p.index, p.index + 100))
} else {
assert_equal(parsed.ok, true)
console.time('eval')
const result = eval_modules(parsed.modules, parsed.sorted).calltree
console.timeEnd('eval')
/* TODO remove
const count_nodes = node => node.children == null
? 1
: 1 + node.children.reduce(
(total, c) => total + count_nodes(c),
0,
)
console.log(
Object.entries(result)
.map(([k,v]) => count_nodes(v.calls))
.reduce((total, c) => total +c)
)
*/
///const frame = eval_frame(result[''].calls, result)
///log('f', frame.children[frame.children.length - 1])
///assert_equal(
/// frame.children[frame.children.length - 1].result.value.value,
/// 1
///)
}
})
])

1998
test/test.js Normal file

File diff suppressed because it is too large Load Diff

141
test/utils.js Normal file
View File

@@ -0,0 +1,141 @@
import {parse, print_debug_node, load_modules} from '../src/parse_js.js'
import {eval_tree, eval_frame} from '../src/eval.js'
import {get_initial_state} from '../src/cmd.js'
Object.assign(globalThis, {log: console.log})
export const parse_modules = (entry, modules) =>
load_modules(entry, module_name => modules[module_name])
export const assert_code_evals_to = (codestring, expected) => {
const parse_result = parse(codestring)
assert_equal(parse_result.ok, true)
const tree = eval_tree(parse_result.node)
const frame = eval_frame(tree)
const result = frame.children[frame.children.length - 1].result
assert_equal({ok: result.ok, value: result.value}, {ok: true, value: expected})
return frame
}
export const assert_code_error = (codestring, error) => {
const parse_result = parse(codestring)
assert_equal(parse_result.ok, true)
const tree = eval_tree(parse_result.node)
const frame = eval_frame(tree)
const result = frame.children[frame.children.length - 1].result
assert_equal(result.ok, false)
assert_equal(result.error, error)
}
export const test_initial_state = code => {
return get_initial_state({
files: typeof(code) == 'object' ? code : { '' : code},
entrypoint: '',
current_module: '',
})
}
export const stringify = val =>
JSON.stringify(val, (key, value) => {
// TODO do not use instanceof because currently not implemented in parser
if(value?.constructor == Set){
return [...value]
} 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 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)
// Wrap to Function constructor to hide from calltree view
// TODO in calltree view, hide fn which has special flag set (see
// filter_calltree)
export const run = Object.defineProperty(new Function('tests', `
const run_test = t => {
try {
t.test()
} 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 failer if
// any. So we will mark root calltree node if one of tests failed
const failure = tests_to_run.reduce(
(failure, t) => {
const next_failure = run_test(t)
return failure ?? next_failure
},
null
)
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 {
run_test(test)
if(globalThis.process != null) {
console.log('Ok')
}
}
}
`), 'name', {value: 'run'})