initial
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
README.html
|
||||
117
README.md
Normal 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
|
||||

|
||||
|
||||
- All values are immutable. You create new values by applying change to old values
|
||||

|
||||
|
||||
- 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
|
||||

|
||||
|
||||
- and inspect any intermediate values
|
||||

|
||||
|
||||
- Expressions that were evaluated have blue background. And that were not reached
|
||||
have white background.
|
||||

|
||||
|
||||
- Expressions that throw errors are red
|
||||

|
||||
|
||||
- When you put cursor inside function, the first call of this function is found
|
||||

|
||||
|
||||
- You can edit this function and immediately see result
|
||||

|
||||
|
||||
- Leporello is (mostly) self-hosted, i.e. built in itself
|
||||

|
||||
|
||||
|
||||
## 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:
|
||||

|
||||
|
||||
- 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
8
ace/ext-searchbox.js
Normal file
5895
ace/keybinding-vim.js
Normal file
797
ace/mode-javascript.js
Normal 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
|
After Width: | Height: | Size: 16 KiB |
BIN
docs/images/edit.gif
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
docs/images/error.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/images/exec.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
docs/images/follow_cursor.gif
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
docs/images/immutable.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
docs/images/inspect.gif
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
docs/images/mutation.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/images/nav.gif
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
docs/images/self-hosted.png
Normal file
|
After Width: | Height: | Size: 232 KiB |
BIN
docs/images/test.png
Normal file
|
After Width: | Height: | Size: 219 KiB |
347
index.html
Normal 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
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type" : "module"
|
||||
}
|
||||
30
service_worker.js
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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'),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
356
src/editor/value_explorer.js
Normal 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
@@ -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
3
src/feature_flags.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export const FLAGS = {
|
||||
embed_value_explorer: true,
|
||||
}
|
||||
135
src/filesystem.js
Normal 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
@@ -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
@@ -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
@@ -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
48
src/reserved.js
Normal 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
@@ -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
@@ -0,0 +1,4 @@
|
||||
import {tests} from './test.js'
|
||||
import {run} from './utils.js'
|
||||
|
||||
run(tests)
|
||||
82
test/self_hosted_test.js
Normal 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
141
test/utils.js
Normal 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'})
|
||||