├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── LICENSE.txt ├── README.md ├── doc └── language.md ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── array.ts ├── base.ts ├── block.ts ├── builtins.ts ├── choice.ts ├── code.ts ├── container.ts ├── edit.ts ├── exports.ts ├── head.ts ├── history.ts ├── item.ts ├── metadata.ts ├── parser.ts ├── path.ts ├── reference.ts ├── tokenize.ts ├── try.ts ├── util.ts ├── value.ts └── workspace.ts ├── test ├── array.test.ts ├── basic.test.ts ├── edit.test.ts ├── exports.ts └── update.test.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | *.plist 2 | .UlyssesRoot 3 | .DS_Store 4 | *.code-workspace 5 | npm-debug.log* 6 | node_modules/ 7 | dist/ 8 | report.*.json 9 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "chrome", 9 | "request": "launch", 10 | "name": "Chrome", 11 | "url": "http://localhost:8080", 12 | "webRoot": "${workspaceFolder}", 13 | "smartStep": true, 14 | "skipFiles": [ 15 | "/**", 16 | "${workspaceFolder}/node_modules/**" 17 | ], 18 | }, 19 | { 20 | "type": "node", 21 | // Must be called this so vscode-jest will use for debugging 22 | "name": "vscode-jest-tests", 23 | "request": "launch", 24 | "args": [ 25 | "--runInBand" 26 | ], 27 | "cwd": "${workspaceFolder}", 28 | // set to internalConsole instead of embeddedTerminal to hide 29 | "console": "internalConsole", 30 | "internalConsoleOptions": "neverOpen", 31 | "disableOptimisticBPs": true, 32 | "program": "${workspaceFolder}/node_modules/jest/bin/jest", 33 | "smartStep": true, 34 | "skipFiles": [ 35 | "/**", 36 | "${workspaceFolder}/node_modules/**" 37 | ], 38 | } 39 | ] 40 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | } -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | This work is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. To view a copy of this license, visit http://creativecommons.org/licenses/by-nc-sa/4.0/ or send a letter to Creative Commons, PO Box 1866, Mountain View, CA 94042, USA. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Here lies Subtext 10 2 | 3 | This work is from 2020, starting from the language spec of Subtext 9 4 | which was done in 2019. The highlight might be a new approach to the [Update 5 | Problem](https://alarmingdevelopment.org/?p=1465). 6 | 7 | Here is the highly incomplete [documentation](doc/language.md). The most 8 | theoretically interesting bit is [Feedback](doc/language.md#feedback). I don't advise 9 | trying to read or run the code. -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "subtext", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "The missing link between spreadsheets and programming", 6 | "main": "index.js", 7 | "directories": { 8 | "doc": "doc" 9 | }, 10 | "devDependencies": { 11 | "@types/jest": "^26.0.23", 12 | "jest": "^27.0.3", 13 | "ts-jest": "~27.0.2", 14 | "typescript": "^4.3.2" 15 | }, 16 | "scripts": { 17 | "test": "jest" 18 | }, 19 | "wallaby": { 20 | "filesWithNoCoverageCalculated": [ 21 | "src/*.ts" 22 | ] 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/JonathanMEdwards/subtext.git" 27 | }, 28 | "keywords": [], 29 | "author": "Jonathan Edwards", 30 | "license": "UNLICENSED", 31 | "bugs": { 32 | "url": "https://github.com/JonathanMEdwards/subtext/issues" 33 | }, 34 | "homepage": "https://github.com/JonathanMEdwards/subtext#readme" 35 | } 36 | -------------------------------------------------------------------------------- /src/array.ts: -------------------------------------------------------------------------------- 1 | import { Container, ID, assert, Item, Character, isNumber, isString, Path, another, Value, trap, builtins, Statement, builtinValue, FieldID, Record, Field, Do, cast, Reference, Crash, _Number, CompileError, assertDefined, arrayLast, arrayEquals, Token } from "./exports"; 2 | 3 | /** A _Array contains a variable-sized sequence of items of a fixed type. The 4 | * items are called entries and have numeric IDs, which are ordinal numbers in 5 | * an untracked array and serial numbers in a tracked array */ 6 | export class _Array extends Container> { 7 | 8 | /** whether array is tracked using serial numbers */ 9 | tracked = false; 10 | /** last used serial number. During analysis counts creates and deletes */ 11 | serial = 0; 12 | 13 | /** whether array is sorted by the value of the items */ 14 | sorted = false; 15 | ascending = true; 16 | 17 | /** Template is item with id 0 */ 18 | template!: Entry; 19 | 20 | /** create template */ 21 | createTemplate(): Entry { 22 | let template = new Entry(); 23 | this.template = template; 24 | template.container = this; 25 | template.io = 'data'; 26 | template.formulaType = 'none'; 27 | template.id = 0; 28 | return template; 29 | } 30 | 31 | /** create an entry */ 32 | createEntry(): Entry { 33 | let entry = new Entry(); 34 | // add to end 35 | this.add(entry); 36 | entry.io = 'input'; 37 | entry.formulaType = 'none'; 38 | if (this.tracked) { 39 | // assign new serial number 40 | entry.id = ++this.serial; 41 | } else { 42 | // assign ordinal number 43 | entry.id = this.items.length; 44 | } 45 | return entry; 46 | } 47 | 48 | /** ghost items. Only used when updating a tracked array through a for-all. 49 | * Contains entries with ids greater than the serial #. These are created in 50 | * response to creations in the result of a for-all on the array. A 51 | * corresponding ghost entry will be created in the for-all Loop, which is 52 | * updated by the creation and feeds back into this array */ 53 | // FIXME: this smells. Reify conditional/deleted entries? 54 | ghosts!: Entry[]; 55 | 56 | createGhost(id: number): Entry { 57 | assert(id > this.serial); 58 | let entry: Entry = new Entry; 59 | entry.id = id; 60 | if (!this.ghosts) { 61 | this.ghosts = []; 62 | } 63 | // remove an existing ghost to allow speculative feedbacks 64 | let existing = this.ghosts.findIndex(item => item.id === id); 65 | if (existing !== -1) { 66 | this.ghosts.splice(existing, 0); 67 | } 68 | this.ghosts.push(entry); 69 | entry.container = this; 70 | entry.io = 'data'; 71 | entry.formulaType = 'none'; 72 | entry.setFrom(this.template); 73 | return entry; 74 | } 75 | 76 | 77 | /** Columns, synthesized on demand, not copied. Note override type by forcing 78 | * container of column to be an Array not a Record */ 79 | columns: Field[] = []; 80 | 81 | /** the item with an ID else undefined */ 82 | getMaybe(id: ID): Item | undefined { 83 | if (isNumber(id)) { 84 | if (id === 0) { 85 | // template has id 0 86 | return this.template; 87 | } 88 | if (this.tracked) { 89 | // find serial number if tracked 90 | if (id > this.serial && this.ghosts) { 91 | // ghost item 92 | return this.ghosts.find(item => item.id === id); 93 | } 94 | return this.items.find(item => item.id === id); 95 | } 96 | // use ordinal number if untracked 97 | return this.items[id - 1]; 98 | } 99 | 100 | // use string as ordinal, even if tracked 101 | if (isString(id)) { 102 | let ordinal = Number(id) 103 | if (Number.isFinite(ordinal)) { 104 | // convert string to ordinal 105 | if (ordinal === 0) { 106 | return this.template; 107 | } 108 | return this.items[ordinal - 1]; 109 | } 110 | } 111 | 112 | // synthesize column on demand 113 | if (!(this.template.value instanceof Record)) return undefined; 114 | const field = this.template.value.getMaybe(id); 115 | if (!field) return undefined; 116 | assert(field instanceof Field); 117 | let column = this.columns.find(column => column.id === field.id); 118 | if (!column) { 119 | // synthesize column 120 | column = new Field(); 121 | column.id = field.id; 122 | this.columns.push(column); 123 | // override column container to be Array not Record 124 | column.container = this as unknown as Container; 125 | let columnArray = new _Array; 126 | column.setValue(columnArray); 127 | // define column template from record field 128 | columnArray.createTemplate().setFrom(field); 129 | // copy field instances into column 130 | this.items.forEach(entry => { 131 | let item = entry.get(field.id); 132 | let copy = new Entry; 133 | copy.io = 'output'; 134 | copy.formulaType = 'none'; 135 | copy.id = entry.id; 136 | columnArray.add(copy); 137 | copy.setFrom(item) 138 | }) 139 | } 140 | return column; 141 | } 142 | 143 | // visit template of array 144 | *visit(): IterableIterator { 145 | yield* super.visit(); 146 | yield* this.template.visit(); 147 | } 148 | 149 | // evaluate contents 150 | eval(): void { 151 | // eval template 152 | this.template.eval(); 153 | // eval items 154 | this.items.forEach(item => item.eval()); 155 | } 156 | 157 | // array is blank when it is empty 158 | isBlank() { return this.items.length === 0 } 159 | 160 | uneval() { 161 | this.template.uneval(); 162 | // previously was initializing arrays 163 | // this.serial = 0; 164 | // this.items = []; 165 | } 166 | 167 | copy(srcPath: Path, dstPath: Path): this { 168 | let to = super.copy(srcPath, dstPath); 169 | to.tracked = this.tracked; 170 | to.serial = this.serial; 171 | to.sorted = this.sorted; 172 | to.ascending = this.ascending; 173 | assert(this.template.container === this); 174 | to.template = this.template.copy(srcPath, dstPath); 175 | to.template.container = to; 176 | return to; 177 | } 178 | 179 | // type compatibility requires same type templates 180 | updatableFrom(from: Value, fromPath?: Path, thisPath?: Path): boolean { 181 | return ( 182 | from instanceof _Array 183 | && this.template.updatableFrom(from.template, fromPath, thisPath) 184 | ) 185 | } 186 | 187 | get isGeneric() { 188 | return this.template.value!.isGeneric; 189 | } 190 | 191 | /** value equality */ 192 | equals(other: _Array) { 193 | return ( 194 | this.tracked === other.tracked 195 | && this.sorted === other.sorted 196 | && this.ascending === other.ascending 197 | && this.template.equals(other.template) 198 | && this.serial === other.serial 199 | && super.equals(other) 200 | ) 201 | } 202 | 203 | /** whether this value was transitively copied from another Value without any 204 | * updates. Template must be a copy and serial #s match. During analysis 205 | * deletes also increment the serial #. */ 206 | isCopyOf(ancestor: this): boolean { 207 | return ( 208 | ancestor instanceof _Array 209 | && this.template.value!.isCopyOf(ancestor.template.value!) 210 | && this.serial === ancestor.serial 211 | && super.isCopyOf(ancestor) 212 | ) 213 | } 214 | 215 | /** whether this is a text-like array */ 216 | get isText() { 217 | return this.template.value instanceof Character; 218 | } 219 | 220 | /** returns string value. Traps if not text-like */ 221 | asString(): string { 222 | assert(this.isText); 223 | return this.items.map(item => cast(item.value, Character).value).join(''); 224 | } 225 | 226 | // dump as an array 227 | dump(): any { 228 | if (this.isText) { 229 | // dump as a string 230 | return this.asString(); 231 | } 232 | return this.items.map(item => item.dump()) 233 | } 234 | } 235 | 236 | export class Entry extends Item { 237 | // return previous item of entire array 238 | previous(): Item | undefined { 239 | return this.container.containingItem.previous(); 240 | } 241 | 242 | } 243 | 244 | /** Text is an untracked array of characters, but is stored as a JS string and 245 | * expanded into an array on demand */ 246 | export class Text extends _Array { 247 | 248 | // Workaround in TS 4 to override property with accessor 249 | // Really this is perf op so should just eagerly synthesize 250 | constructor() { 251 | super(); 252 | Object.defineProperty(this, "template", { 253 | // synthesize template on demand 254 | get() { 255 | if (!this._template) { 256 | // template is a space character, the default Character 257 | this.createTemplate().setValue(new Character); 258 | } 259 | return (this as Text)._template!; 260 | }, 261 | set(entry: Entry) { 262 | this._template = entry; 263 | } 264 | }) 265 | Object.defineProperty(this, "items", { 266 | // synthesize items on demand 267 | get() { 268 | if (!this._items) { 269 | this._items = Array.from(this.value as string).map((char, i) => { 270 | let entry = new Entry(); 271 | entry.container = this; 272 | entry.id = i + 1; 273 | entry.io = 'output'; 274 | entry.formulaType = 'none'; 275 | let value = new Character; 276 | value.value = char; 277 | entry.setFrom(value); 278 | return entry; 279 | }) 280 | } 281 | return (this as Text)._items; 282 | }, 283 | set(value: Entry[]) { 284 | // ignore superclass initialization 285 | assert(value.length === 0); 286 | trap(); 287 | } 288 | }) 289 | } 290 | 291 | 292 | tracked = false; 293 | sorted = false; 294 | 295 | /** JS string value */ 296 | value: string = ''; 297 | 298 | get isText() { return true; } 299 | asText() { return this.value; } 300 | 301 | // synthesize template on demand 302 | private _template?: Entry; 303 | 304 | // synthesize items on demand 305 | private _items?: Entry[]; 306 | 307 | get isGeneric() { return false; } 308 | 309 | eval() { } 310 | 311 | uneval() { } 312 | 313 | copy(srcPath: Path, dstPath: Path): this { 314 | // just copy string value 315 | let to = another(this); 316 | to.source = this; 317 | to.value = this.value; 318 | if (this.analyzing) { 319 | // copy template during analysis to track copying 320 | to.createTemplate().setValue(this.template.value!.copy(srcPath, dstPath)); 321 | } 322 | return to; 323 | } 324 | 325 | // dump as string 326 | dump() { return this.value }; 327 | } 328 | 329 | 330 | 331 | 332 | /** A Loop iterates a do-block over the input array */ 333 | export class Loop extends _Array { 334 | 335 | /** type of loop */ 336 | loopType!: 'find?' | 'find!' | 'for-all' | 'such-that' | 'all?' | 'all!' 337 | | 'none?' | 'none!' | 'accumulate' | 'selecting'; 338 | 339 | /** input array, set on eval */ 340 | input!: _Array; 341 | 342 | // evaluate contents 343 | eval(): void { 344 | // use template evaluation bit to signify eval of whole loop 345 | if (this.template.evaluated) return; 346 | // eval template 347 | this.template.eval(); 348 | let templateBlock = this.template.value!; 349 | const blockInput = templateBlock.items[0]; 350 | // Input of loop template block is reference to source array template 351 | blockInput.used = true; 352 | let sourceTemplate = 353 | cast(blockInput.get('^reference').value, Reference).target!; 354 | assert(sourceTemplate.id === 0); 355 | assert(sourceTemplate.container instanceof _Array); 356 | this.input = sourceTemplate.container; 357 | 358 | // mimic tracking of input array 359 | this.tracked = this.input.tracked; 360 | this.serial = this.input.serial; 361 | 362 | if (this.loopType === 'accumulate' && this.analyzing) { 363 | // TODO: type check accumulator and result 364 | let accum = templateBlock.items[1]; 365 | let result = templateBlock.result!; 366 | if (!accum.value!.updatableFrom(result.value!)) { 367 | throw new CompileError(accum.value!.token, 'result must be same type as accumulator') 368 | } 369 | } 370 | 371 | // iterate over input array 372 | this.input.items.forEach(item => { 373 | let iteration = new Entry(); 374 | this.add(iteration); 375 | iteration.id = item.id; 376 | iteration.io = 'output'; 377 | iteration.formulaType = 'none'; 378 | // copy code block into iteration 379 | iteration.setFrom(templateBlock); 380 | iteration.uneval(); 381 | // set iteration source reference to source entry 382 | let iterInput = iteration.value!.items[0]; 383 | assert(iterInput.formulaType === 'reference'); 384 | let iterRef = cast(iterInput.get('^reference').value, Reference); 385 | assert(arrayLast(iterRef.path.ids) === 0); 386 | iterRef.path = item.path; 387 | assert(!iterRef.target); 388 | 389 | if (this.loopType === 'accumulate' && item !== this.input.items[0]) { 390 | // set previous result into accumulater 391 | let prev = cast(this.items[this.items.length - 2].value, Do); 392 | let accum = iteration.value!.items[1]; 393 | assert(accum.io === 'input'); 394 | accum.setFrom(prev.result) 395 | } 396 | 397 | // evaluate iteration 398 | iteration.eval(); 399 | }) 400 | } 401 | 402 | /** extract results of a loop into a statement */ 403 | execute(statement: Statement) { 404 | let guarded = this.loopType.endsWith('?'); 405 | statement.setConditional(guarded); 406 | let templateBlock = this.template.value!; 407 | 408 | switch (this.loopType) { 409 | 410 | 411 | // find first non-rejecting block 412 | case 'find?': 413 | case 'find!': { 414 | if (!templateBlock.conditional) { 415 | throw new CompileError(templateBlock.token, 'block must be conditional'); 416 | } 417 | let index = 0; 418 | if (!this.analyzing) { 419 | index = 420 | this.items.findIndex(iteration => !iteration.value!.rejected) + 1; 421 | if (!index) { 422 | if (!guarded) { 423 | throw new Crash(this.token, 'assertion failed') 424 | } 425 | statement.rejected = true; 426 | } 427 | } 428 | let item = index ? this.items[index - 1] : this.template; 429 | // use input item from block 430 | statement.setFrom(item.value!.items[0]) 431 | // export index 432 | let indexField = new Field; 433 | // copy fieldID of index export from at function 434 | // TODO: define this statically 435 | indexField.id = cast( 436 | this.workspace.currentVersion.down('builtins.at.^code.index').id, 437 | FieldID); 438 | indexField.io = 'output'; 439 | indexField.formulaType = 'none'; 440 | indexField.setFrom(index); 441 | let indexRecord = new Record; 442 | indexRecord.add(indexField); 443 | let exportField = statement.replaceMeta('^export', indexRecord); 444 | exportField.setConditional(guarded); 445 | return; 446 | } 447 | 448 | 449 | // map an array through a function 450 | case 'for-all': { 451 | if (templateBlock.conditional) { 452 | throw new CompileError(templateBlock.token, 453 | 'block cannot be conditional'); 454 | } 455 | 456 | // create result array 457 | let resultArray = new _Array; 458 | statement.setFrom(resultArray); 459 | resultArray.tracked = this.input.tracked; 460 | resultArray.serial = this.input.serial; 461 | // result isn't sorted FIXME: should it be? 462 | resultArray.sorted = false; 463 | // define template from result of template block 464 | /** This is the reason we can't combine filtering with mapping: the 465 | * filter might reject the template of the source. We could allow this, 466 | * and use the value produced by the rejected filter, but that would 467 | * require documenting what every conditional functional does in that 468 | * case. Not worth the doc overhead */ 469 | resultArray.createTemplate().setFrom(templateBlock.result); 470 | 471 | // copy results of loop into result array 472 | this.items.forEach(iteration => { 473 | let iterationBlock = assertDefined(iteration.value); 474 | let resultItem = new Entry; 475 | resultArray.add(resultItem); 476 | if (resultArray.tracked) { 477 | // copy ID from tracked array 478 | resultItem.id = iteration.id; 479 | } else { 480 | // set ordinals if untracked 481 | resultItem.id = resultArray.items.length; 482 | } 483 | resultItem.io = 'input'; 484 | resultItem.formulaType = 'none'; 485 | resultItem.setFrom(iterationBlock.result); 486 | }) 487 | return; 488 | } 489 | 490 | 491 | // filter 492 | case 'such-that': { 493 | 494 | if (!templateBlock.conditional) { 495 | throw new CompileError(templateBlock.token, 496 | 'block must be conditional'); 497 | } 498 | 499 | // create result array 500 | let resultArray = new _Array; 501 | statement.setFrom(resultArray); 502 | resultArray.tracked = this.input.tracked; 503 | resultArray.serial = this.input.serial; 504 | resultArray.sorted = this.input.sorted; 505 | // copy source template 506 | resultArray.createTemplate().setFrom(this.input.template); 507 | 508 | // copy source items into result array when not rejected 509 | this.items.forEach((iteration, i) => { 510 | let iterationBlock = assertDefined(iteration.value); 511 | if (iterationBlock.rejected) { 512 | // skip rejections 513 | return; 514 | } 515 | let resultItem = this.input.items[i].copy( 516 | this.input.containingPath, statement.path); 517 | resultArray.add(resultItem); 518 | if (!resultArray.tracked) { 519 | // set ordinals if untracked 520 | resultItem.id = resultArray.items.length; 521 | } 522 | }) 523 | return; 524 | } 525 | 526 | 527 | // selection 528 | case 'selecting': { 529 | 530 | if (!templateBlock.conditional) { 531 | throw new CompileError(templateBlock.token, 532 | 'block must be conditional'); 533 | } 534 | 535 | // copy result from source selection 536 | // get source selection from input array, which is selection.backing 537 | let selection = this.input.containingItem.container; 538 | if (!(selection instanceof Selection)) { 539 | throw new CompileError(statement, 540 | 'selecting block requires a selection'); 541 | } 542 | statement.setFrom(selection); 543 | let result = statement.value as Selection; 544 | result.selected = []; 545 | 546 | // select unrejected array items 547 | this.items.forEach((iteration, i) => { 548 | let iterationBlock = assertDefined(iteration.value); 549 | if (iterationBlock.rejected) { 550 | // skip rejections 551 | return; 552 | } 553 | result.selected.push(iteration.id); 554 | }) 555 | return; 556 | } 557 | 558 | 559 | // test filtering 560 | case 'all?': 561 | case 'all!': 562 | case 'none?': 563 | case 'none!': { 564 | 565 | const all = this.loopType.startsWith('all'); 566 | if (!templateBlock.conditional) { 567 | throw new CompileError(templateBlock.token, 'block must be conditional'); 568 | } 569 | // reject if any/no iteration succeeded 570 | if (this.items.find(iteration => { 571 | let rejected = iteration.value!.rejected; 572 | return all ? rejected : !rejected 573 | })) { 574 | statement.rejected = true; 575 | if (this.loopType.endsWith('!') && !this.analyzing) { 576 | // failed assertion 577 | throw new Crash(this.token, 'assertion failed') 578 | } 579 | } 580 | 581 | // copy input array to result 582 | statement.setFrom(this.input); 583 | return; 584 | } 585 | 586 | 587 | case 'accumulate': { 588 | if (templateBlock.conditional) { 589 | throw new CompileError(templateBlock.token, 'block cannot be conditional'); 590 | } 591 | if (this.items.length) { 592 | // return last result 593 | statement.setFrom(arrayLast(this.items).value!.result) 594 | } else { 595 | // return initial accumulator value 596 | statement.setFrom(templateBlock.items[1]); 597 | } 598 | return; 599 | } 600 | 601 | 602 | default: trap(); 603 | } 604 | } 605 | 606 | copy(srcPath: Path, dstPath: Path): this { 607 | // suppress copying iterations - they will be recomputed 608 | let items = this.items; 609 | this.items = []; 610 | let to = super.copy(srcPath, dstPath); 611 | this.items = items; 612 | to.loopType = this.loopType; 613 | return to; 614 | } 615 | } 616 | 617 | /** Selection subclasses Reference to the backing array */ 618 | export class Selection extends Reference { 619 | 620 | /** the backing array */ 621 | get backing() { 622 | return this.target!.value as _Array; 623 | } 624 | 625 | /** whether this is a generic selection */ 626 | generic = false; 627 | get isGeneric() { 628 | return this.generic; 629 | } 630 | 631 | /** selected tracking ids in backing array. Sorted numerically. TODO: sort by 632 | * position if array reorderable */ 633 | selected: number[] = []; 634 | 635 | // selection is blank when nothing is selected 636 | isBlank() { return this.selected.length === 0 } 637 | 638 | /** 1-based indexes of selected items in base array */ 639 | get indexes() { 640 | return this.selected.map(id => 641 | this.backing.items.findIndex(item => item.id === id) + 1) 642 | } 643 | 644 | // select an id 645 | select(id: number) { 646 | this.eval() 647 | let selected = this.selected; 648 | if (selected.indexOf(id) >= 0) { 649 | // already selected 650 | return; 651 | } 652 | assert(this.backing.getMaybe(id)); 653 | // insert in sort order 654 | let index = selected.findIndex(existing => existing > id); 655 | if (index < 0) { 656 | index = selected.length; 657 | } 658 | selected.splice(index, 0, id); 659 | } 660 | 661 | // deselect an id 662 | deselect(id: number) { 663 | this.eval() 664 | let index = this.selected.indexOf(id); 665 | if (index < 0) { 666 | // not selected 667 | return; 668 | } 669 | this.selected.splice(index, 1); 670 | } 671 | 672 | eval() { 673 | // bind array reference 674 | super.eval(); 675 | if (this.conditional) { 676 | throw new CompileError(this.containingItem, 677 | 'selection reference can not be conditional') 678 | } 679 | this.postEval(); 680 | } 681 | 682 | // eval for Selection 683 | protected postEval() { 684 | let array = this.target!.value!; 685 | if (this.analyzing) { 686 | if (!(array instanceof _Array)) { 687 | throw new CompileError(this.containingItem, 688 | 'selection requires an array reference') 689 | } 690 | if (!array.tracked) { 691 | throw new CompileError(this.containingItem, 692 | 'selection requires a tracked array') 693 | } 694 | } 695 | // auto-delete missing selections in an input field 696 | if (this.containingItem.inputLike) { 697 | this.selected = this.selected.filter(id => array.getMaybe(id)) 698 | } 699 | } 700 | 701 | bind() { 702 | if (this.generic) { 703 | // bind to generic array compiled into ^any 704 | this.path = this.containingItem.get('^any').path; 705 | this.guards = this.path.ids.map(() => undefined); 706 | this.context = this.path.length; 707 | } else { 708 | // bind reference from selection 709 | super.bind(this.containingItem); 710 | // disallow nested selections 711 | this.path.ids.slice( 712 | this.path.lub(this.containingPath).ids.length 713 | ).forEach(id => { 714 | if (typeof id === 'number') { 715 | throw new CompileError(this.containingItem, 'selection in nested table not yet supported') 716 | } 717 | }) 718 | } 719 | } 720 | 721 | /** synthetic fields created on demand. Not copied */ 722 | fields: Field[] = []; 723 | 724 | /** access synthetic fields */ 725 | getMaybe(id: ID): Item | undefined { 726 | 727 | if (id === 0) { 728 | // access backing array template inside synthetic backing field 729 | // so selceting loop can infer the selection 730 | return this.get(FieldID.predefined.backing).get(0); 731 | } 732 | 733 | if (typeof id === 'number') { 734 | return undefined; 735 | } 736 | if (typeof id === 'string') { 737 | // convert string to predefined FieldID 738 | id = FieldID.predefined[id]; 739 | if (!id) { 740 | return undefined 741 | } 742 | } 743 | let existing = this.fields.find(field => field.id === id); 744 | if (existing) { 745 | return existing; 746 | } 747 | 748 | // synthesize field 749 | const field = new Field; 750 | field.id = id; 751 | field.container = this as unknown as Container; 752 | field.io = 'interface'; // make field updatable 753 | field.formulaType = 'none'; 754 | const backing = this.backing; 755 | 756 | switch (id) { 757 | 758 | // selected backing items 759 | case FieldID.predefined.selections: { 760 | let selected = new _Array; 761 | field.setValue(selected); 762 | selected.tracked = true; 763 | selected.serial = backing.serial; 764 | selected.sorted = backing.sorted; 765 | selected.createTemplate().setFrom(backing.template); 766 | this.selected.forEach(selectedID => { 767 | let item = backing.get(selectedID) as Entry; 768 | let selectedItem = item.copy( 769 | backing.containingPath, field.path); 770 | selected.add(selectedItem); 771 | }) 772 | break; 773 | } 774 | 775 | // backing array 776 | case FieldID.predefined.backing: { 777 | field.copyValue(backing.containingItem); 778 | break; 779 | } 780 | 781 | // single selected backing item 782 | case FieldID.predefined.at: { 783 | field.conditional = true; 784 | if (this.analyzing) { 785 | // in analysis, conditional copy of template 786 | field.rejected = true; 787 | field.setFrom(backing.template); 788 | break; 789 | } 790 | if (this.selected.length !== 1) { 791 | // not one selection 792 | field.rejected = true; 793 | field.evaluated = true; 794 | break; 795 | } 796 | // TODO export index 797 | field.setFrom(backing.get(this.selected[0])) 798 | break; 799 | } 800 | 801 | default: 802 | return undefined; 803 | } 804 | this.fields.push(field); 805 | return field; 806 | } 807 | 808 | // previously was initializing selections 809 | // uneval() { 810 | // super.uneval(); 811 | // this.selected = []; 812 | // this.fields = []; 813 | // } 814 | 815 | copy(srcPath: Path, dstPath: Path): this { 816 | let to = super.copy(srcPath, dstPath); 817 | to.selected = this.selected.slice(); 818 | to.generic = this.generic; 819 | return to; 820 | } 821 | 822 | // type compatibility requires same backing array unless generic 823 | updatableFrom(from: Value, fromPath?: Path, thisPath?: Path): boolean { 824 | if (!(from instanceof Selection)) return false; 825 | if (this.generic) { 826 | // generic selection requires type-compatible arrays 827 | return this.backing.updatableFrom(from.backing, fromPath, thisPath); 828 | } 829 | if (this instanceof Link !== from instanceof Link) { 830 | // TODO: implicitly convert a link into a selection 831 | return false; 832 | } 833 | return super.updatableFrom(from, fromPath, thisPath); 834 | } 835 | 836 | // equality requires backing arrays are the same 837 | equals(other: Selection) { 838 | return this.backing === other.backing && arrayEquals(this.selected, other.selected); 839 | } 840 | 841 | dump(): any { 842 | return this.indexes; 843 | } 844 | } 845 | 846 | /** bidirectional selections between table fields */ 847 | export class Link extends Selection { 848 | 849 | /** whether primary link of pair. The primary comes first in the tree. The 850 | * primary stores the selection state, and the secondary derives it */ 851 | primary!: boolean; 852 | 853 | /** Field name of opposite link in backing array */ 854 | // TODO: allow a path 855 | oppositeFieldName!: Token; 856 | 857 | /** FieldID of opposite link in backing array */ 858 | oppositeFieldID!: FieldID; 859 | 860 | /** whether at the defining location of the link (and hence updatable) or a 861 | * copy. Copies can have arbitrary selections set by select?/deselect? 862 | * functions */ 863 | get atHome() { 864 | // test without evaluating the opposite link 865 | const oppositeLink = 866 | cast(this.backing.template.get(this.oppositeFieldID).value, Link); 867 | return oppositeLink.path.equals(this.containingPath.up(2)); 868 | } 869 | 870 | // eval Link 871 | protected postEval() { 872 | if (this.analyzing && this.containingItem.io === 'input') { 873 | // Link must be a tracked table field 874 | let template = this.containingItem.container; 875 | let thisArray = template.containingItem.container; 876 | if ( 877 | !(template instanceof Record) 878 | || template.containingItem.id !== 0 879 | || !(thisArray instanceof _Array) 880 | || !thisArray.tracked 881 | ) { 882 | throw new CompileError(this.containingItem, 883 | 'link must be a field of a tracked table') 884 | } 885 | let thisFieldID = cast(this.containingItem.id, FieldID); 886 | 887 | let oppositeArray = this.target!.value!; 888 | if ( 889 | !(oppositeArray instanceof _Array) 890 | || !oppositeArray.tracked 891 | || !(oppositeArray.template.value instanceof Record) 892 | ) { 893 | throw new CompileError(this.containingItem, 894 | 'link requires a tracked table') 895 | } 896 | let oppositeField = 897 | oppositeArray.template.getMaybe(this.oppositeFieldName.text); 898 | if (!oppositeField) { 899 | throw new CompileError(this.oppositeFieldName, 900 | 'Opposite link not defined') 901 | } 902 | this.oppositeFieldID = oppositeField.id as FieldID; 903 | oppositeField.eval(); 904 | let oppositeLink = oppositeField.value; 905 | if ( 906 | !(oppositeLink instanceof Link) 907 | || oppositeLink.backing !== thisArray 908 | || oppositeLink.oppositeFieldID !== thisFieldID 909 | ) { 910 | throw new CompileError(this.oppositeFieldName, 911 | 'Opposite link does not match') 912 | } 913 | // first link in tree is primary 914 | this.primary = this.containingItem.comesBefore(oppositeField) 915 | return; 916 | } 917 | 918 | if (!this.atHome) { 919 | // temp copies of link can have arbitrary selections 920 | return; 921 | } 922 | 923 | let array = this.backing; 924 | if (this.primary) { 925 | // auto-delete missing selections in an input field 926 | if (this.containingItem.io === 'input') { 927 | this.selected = this.selected.filter(id => array.getMaybe(id)); 928 | } 929 | return; 930 | } 931 | 932 | // secondary link derives selection by querying primary links 933 | this.selected = []; 934 | const thisID = this.containingItem.container.containingItem.id as number; 935 | if (thisID === 0) { 936 | // selections in template are empty 937 | return; 938 | } 939 | array.items.forEach(item => { 940 | let oppositeLink = cast(item.get(this.oppositeFieldID).value, Link); 941 | oppositeLink.eval(); 942 | if (oppositeLink.selected.includes(thisID)) { 943 | // opposite link selects us 944 | this.selected.push(item.id); 945 | } 946 | }) 947 | } 948 | 949 | // TODO: allow implicit conversion from a compatible Selection 950 | updatableFrom(from: Value, fromPath?: Path, thisPath?: Path): boolean { 951 | return ( 952 | from instanceof Link 953 | && this.oppositeFieldID === from.oppositeFieldID 954 | && super.updatableFrom(from, fromPath, thisPath) 955 | ); 956 | } 957 | 958 | copy(srcPath: Path, dstPath: Path): this { 959 | let to = super.copy(srcPath, dstPath); 960 | to.primary = this.primary; 961 | to.oppositeFieldName = this.oppositeFieldName; 962 | to.oppositeFieldID = this.oppositeFieldID; 963 | return to; 964 | } 965 | 966 | 967 | } 968 | 969 | 970 | export const arrayBuiltinDefinitions = ` 971 | & = do{in: array{anything}; value: in[]; builtin &; export index = 0} 972 | length = do{in: array{anything}; builtin length} 973 | delete? = do{in: array{anything}; index: 0; builtin delete?} 974 | && = do{in: array{anything}; from: in; builtin &&} 975 | at? = do{in: array{anything}; index: 0; builtin at?} 976 | at-or-template = do{in: array{anything}; index: 0; builtin at-or-template} 977 | update? = do{in: array{anything}; index: 0; value: in at-or-template index; builtin update?} 978 | skip-white = do{in: ''; builtin skip-white} 979 | select? = do{in: selection{any array{anything}}; index: 0; builtin select?} 980 | deselect? = do{in: selection{any array{anything}}; index: 0; builtin deselect?} 981 | ` 982 | 983 | /** & array add */ 984 | builtins['&'] = (s: Statement, array: _Array, value: builtinValue) => { 985 | let copy = array.copy(array.containingPath, s.path); 986 | s.setValue(copy); 987 | if (s.analyzing) { 988 | // during analysis just increment serial # to break copying detection 989 | ++copy.serial; 990 | s.exportFrom(0); 991 | return; 992 | } 993 | 994 | let entry = copy.createEntry(); 995 | entry.setFrom(value); 996 | // export index 997 | s.exportFrom(entry.container.items.indexOf(entry) + 1); 998 | } 999 | 1000 | /** concatenate */ 1001 | builtins['&&'] = (s: Statement, a: _Array, b: _Array) => { 1002 | let copy = a.copy(a.containingPath, s.path); 1003 | s.setValue(copy); 1004 | if (s.analyzing) { 1005 | // during analysis just increment serial # to break copying detection 1006 | ++copy.serial; 1007 | return; 1008 | } 1009 | 1010 | b.items.forEach((item, i) => { 1011 | // renumner id of copy if untracked 1012 | let id = a.tracked ? item.id : a.items.length + i + 1; 1013 | let copiedItem = item.copy(item.path, s.path.down(id)); 1014 | copiedItem.id = id; 1015 | copy.add(copiedItem); 1016 | }) 1017 | } 1018 | 1019 | /** array length */ 1020 | builtins['length'] = (s: Statement, array: _Array) => { 1021 | s.setFrom(array.items.length); 1022 | } 1023 | 1024 | /** delete at index */ 1025 | builtins['delete?'] = (s: Statement, array: _Array, index: number) => { 1026 | let accepted = 0 < index && index <= array.items.length; 1027 | s.setAccepted(accepted); 1028 | let copy = array.copy(array.containingPath, s.path); 1029 | s.setValue(copy); 1030 | if (s.analyzing) { 1031 | // during analysis just increment serial # to break copying detection 1032 | ++copy.serial; 1033 | return; 1034 | } 1035 | 1036 | if (accepted) { 1037 | copy.items.splice(index - 1, 1); 1038 | if (!array.tracked) { 1039 | // renumber if untracked 1040 | copy.items.slice(index - 1).forEach(item => { 1041 | item.id--; 1042 | }) 1043 | } 1044 | } 1045 | } 1046 | 1047 | /** array indexing */ 1048 | builtins['at?'] = (s: Statement, array: _Array, index: number) => { 1049 | let accepted = 0 < index && index <= array.items.length; 1050 | s.setAccepted(accepted); 1051 | s.setFrom(accepted ? array.items[index - 1] : array.template); 1052 | } 1053 | 1054 | /** array indexing with failure to template */ 1055 | builtins['at-or-template'] = (s: Statement, array: _Array, index: number) => { 1056 | s.setFrom(array.items[index - 1] ?? array.template); 1057 | } 1058 | 1059 | /** array update */ 1060 | builtins['update?'] = ( 1061 | s: Statement, array: _Array, index: number, value: builtinValue 1062 | ) => { 1063 | let accepted = 0 < index && index <= array.items.length; 1064 | s.setAccepted(accepted); 1065 | let copy = array.copy(array.containingPath, s.path); 1066 | s.setValue(copy); 1067 | if (s.analyzing) { 1068 | // force change during analysis 1069 | s.uncopy(); 1070 | } 1071 | if (accepted) { 1072 | let item = copy.items[index - 1]; 1073 | item.detachValue(); 1074 | item.setFrom(value); 1075 | } 1076 | } 1077 | 1078 | builtins['skip-white'] = (s: Statement, a: _Array) => { 1079 | s.setFrom(a.asString().trimStart()); 1080 | } 1081 | 1082 | /** selections */ 1083 | builtins['select?'] = (s: Statement, sel: Selection, index: number) => { 1084 | let item = sel.backing.items[index - 1]; 1085 | s.setAccepted(!!item); 1086 | // modify selection 1087 | let result = sel.copy(sel.containingPath, s.path); 1088 | s.setFrom(result); 1089 | result.eval(); 1090 | if (item) { 1091 | result.select(item.id); 1092 | } 1093 | } 1094 | 1095 | builtins['deselect?'] = (s: Statement, sel: Selection, index: number) => { 1096 | let item = sel.backing.items[index - 1]; 1097 | s.setAccepted(!!item); 1098 | // modify selection 1099 | let result = sel.copy(sel.containingPath, s.path); 1100 | s.setFrom(result); 1101 | result.eval(); 1102 | if (item) { 1103 | result.deselect(item.id); 1104 | } 1105 | } 1106 | 1107 | -------------------------------------------------------------------------------- /src/base.ts: -------------------------------------------------------------------------------- 1 | import { Value, Path, assert } from "./exports"; 2 | 3 | /** Base items contain no other items */ 4 | export abstract class Base extends Value { 5 | 6 | // base values already evaluated by default 7 | eval() { } 8 | 9 | uneval() { } 10 | 11 | dump() { } 12 | 13 | // Validate copy test 14 | isCopyOf(ancestor: this): boolean { 15 | if (super.isCopyOf(ancestor)) { 16 | assert(this.equals(ancestor)) 17 | return true; 18 | } else { 19 | return false; 20 | } 21 | } 22 | 23 | } 24 | 25 | /** 26 | * A JS number. 27 | * NaN is the blank number `###` which is equal to itself. 28 | */ 29 | export class _Number extends Base { 30 | value: number = NaN; 31 | 32 | isBlank() { return Number.isNaN(this.value); } 33 | 34 | copy(srcPath: Path, dstPath: Path): this { 35 | let to = super.copy(srcPath, dstPath); 36 | to.value = this.value; 37 | return to; 38 | } 39 | 40 | equals(other: any) { 41 | return ( 42 | other instanceof _Number 43 | && ( 44 | this.value === other.value 45 | || this.isBlank() && other.isBlank())); 46 | } 47 | 48 | // dump as number 49 | dump() { return this.value }; 50 | } 51 | 52 | /** a JS character */ 53 | export class Character extends Base { 54 | /** value is a single-character string. could be a charCode instead */ 55 | value: string = ' '; 56 | 57 | // space is the blank value 58 | isBlank() { return this.value === ' '; } 59 | 60 | copy(srcPath: Path, dstPath: Path): this { 61 | let to = super.copy(srcPath, dstPath); 62 | to.value = this.value; 63 | return to; 64 | } 65 | 66 | equals(other: any) { 67 | return other instanceof Character && this.value === other.value; 68 | } 69 | 70 | // dump as string 71 | dump() { return this.value }; 72 | } 73 | 74 | /** Nil is the unit type with one value */ 75 | export class Nil extends Base { 76 | 77 | isBlank() { return true; } 78 | 79 | // JS value is null 80 | get value() { return null }; 81 | 82 | equals(other: any) { 83 | return other instanceof Nil; 84 | } 85 | 86 | // dump as null 87 | dump() { return null }; 88 | } 89 | 90 | /** Anything is the top type used in generics */ 91 | export class Anything extends Base { 92 | 93 | isBlank() { return true; } 94 | 95 | equals(other: any) { 96 | return other instanceof Anything; 97 | } 98 | 99 | get isGeneric() { return true } 100 | 101 | // anything is compatible 102 | updatableFrom(from: Value, fromPath?: Path, thisPath?: Path): boolean { 103 | return true; 104 | } 105 | 106 | // dump as undefined 107 | dump() { return undefined }; 108 | } -------------------------------------------------------------------------------- /src/block.ts: -------------------------------------------------------------------------------- 1 | import { assert, Container, ID, Item, isNumber, Token, Path, Dictionary, Value, trap, CompileError, Link, Reference, Nil } from "./exports"; 2 | 3 | /** A Block is a record-like container with a fixed set of items called fields. 4 | * Each field can have a different type. Each Field has a globally unique 5 | * FieldID which optionally gives it a name. */ 6 | 7 | export class Block extends Container { 8 | 9 | /** whether block is displayed as an outline or a single line */ 10 | outlined = true; 11 | 12 | get fields(): F[] { 13 | return this.items; 14 | } 15 | 16 | /** add a Field */ 17 | add(field: F) { 18 | this.fields.push(field); 19 | assert(!field.container); 20 | field.container = this; 21 | } 22 | 23 | /** the item with an ID else undefined */ 24 | getMaybe(id: ID): F | undefined { 25 | if (id instanceof FieldID) { 26 | return this.fields.find(field => field.id === id); 27 | } 28 | if (isNumber(id)) { 29 | // use number as ordinal index 30 | return this.fields[id - 1]; 31 | } 32 | let ordinal = Number(id) 33 | if (Number.isFinite(ordinal)) { 34 | // convert string to ordinal 35 | return this.fields[ordinal - 1]; 36 | } 37 | // search by name 38 | return this.fields.find(field => field.name === id); 39 | } 40 | 41 | // block is blank is all fields are blank 42 | isBlank() { 43 | return this.fields.every(field => field.value!.isBlank()) 44 | } 45 | 46 | /** evaluate all fields */ 47 | eval() { 48 | this.fields.forEach(field => { 49 | field.eval(); 50 | if (this.analyzing) { 51 | if (field.inputLike && field.conditional) { 52 | throw new CompileError(field, 'input fields must be unconditional') 53 | } 54 | 55 | // verify conditional naming 56 | if ( 57 | field.id.name && field.id.token 58 | && field.id.token.text.endsWith('?') !== !!field.conditional 59 | ) { 60 | throw new CompileError( 61 | field, 62 | field.conditional 63 | ? 'conditional field name must have suffix ?' 64 | : 'unconditional field name cannot have suffix ?' 65 | ) 66 | } 67 | 68 | // verify reference hygiene on inputs 69 | // reference from value into metadata not allowed, because might change 70 | if (field.inputLike && field.value instanceof Container) { 71 | for (let item of field.value.visit()) { 72 | if ( 73 | item.value instanceof Reference 74 | && item.value.path // possible in recursive choice 75 | && field.path.metadataContains(item.value.path) 76 | ) { 77 | throw new CompileError(item, 'input field value referencing its own formula') 78 | } 79 | } 80 | 81 | } 82 | } 83 | }) 84 | } 85 | 86 | copy(srcPath: Path, dstPath: Path): this { 87 | let to = super.copy(srcPath, dstPath); 88 | to.outlined = this.outlined; 89 | return to; 90 | } 91 | 92 | /** whether contains an input field with an Anything value */ 93 | get isGeneric() { 94 | return this.fields.some( 95 | field => field.io === 'input' && field.value!.isGeneric) 96 | } 97 | 98 | // type compatibility 99 | updatableFrom(from: Block, fromPath?: Path, thisPath?: Path): boolean { 100 | return ( 101 | super.updatableFrom(from, fromPath, thisPath) 102 | && this.fields.length === from.fields.length 103 | && this.fields.every((field, i) => 104 | field.updatableFrom(from.fields[i], fromPath, thisPath)) 105 | ) 106 | } 107 | 108 | // dump as an object 109 | dump() { 110 | let obj: Dictionary = {}; 111 | this.fields.forEach((field, i) => { 112 | // skip includes and deletes 113 | if (field.deleted || field.formulaType === 'include') return; 114 | // rejected fields are dumped as false 115 | obj[field.name ?? i + 1] = field.rejected ? false : field.dump(); 116 | }) 117 | return obj; 118 | } 119 | } 120 | 121 | /** Field is an Item with a FieldID and a Value */ 122 | export class Field extends Item { 123 | 124 | get name() { return this.id.name } 125 | 126 | /** Field deleted. Value is Nil. Left as a tombstone to preserve order of 127 | * ::insert edits */ 128 | deleted?: boolean; 129 | 130 | /** delete field */ 131 | delete() { 132 | this.deleted = true; 133 | // set value to Nil 134 | this.detachValueIf(); 135 | this.setValue(new Nil); 136 | this.formulaType = 'none'; 137 | // delete metadata 138 | this.metadata = undefined; 139 | } 140 | 141 | copy(srcPath: Path, dstPath: Path): this { 142 | let to = super.copy(srcPath, dstPath); 143 | to.deleted = this.deleted; 144 | return to; 145 | } 146 | } 147 | 148 | /** space-unique ID of a Field. Immutable and interned */ 149 | export class FieldID { 150 | 151 | /** space serial # of field. Negative #'s for predefined IDs */ 152 | readonly serial: number; 153 | 154 | /** name of field, without trailing ?. undefined if unnamed */ 155 | name?: string; 156 | 157 | /** source token where defined, for parsing errors */ 158 | token?: Token; 159 | 160 | constructor(serial: number) { 161 | this.serial = serial; 162 | } 163 | 164 | /** predefine FieldIDs with negative serial #s */ 165 | private static predefine(names: string[]): Dictionary { 166 | let dict: Dictionary = {}; 167 | let serial = 0; 168 | names.forEach(name => { 169 | let id = new FieldID(--serial) 170 | id.name = name; 171 | dict[name] = id; 172 | }) 173 | return dict; 174 | } 175 | static predefined: Dictionary = FieldID.predefine([ 176 | 'selections', // selection filtering of backing array 177 | 'backing', // selection backing array 178 | 'at', // single selected backing item 179 | ]); 180 | 181 | toString() { 182 | return this.name ?? this.serial.toString(); 183 | } 184 | } 185 | 186 | /** data block */ 187 | export class Record extends Block { 188 | private _nominal: undefined; 189 | } -------------------------------------------------------------------------------- /src/builtins.ts: -------------------------------------------------------------------------------- 1 | import { Item, cast, Do, assert, _Number, Character, Text, Dictionary, Value, Statement, arrayLast, Base, assertDefined, Nil, _Array, CompileError, Metafield, Choice, Anything} from "./exports" 2 | 3 | // extract input values. Converts a _Number to number 4 | function inputs(statement: Statement): builtinValue[] { 5 | let block = cast(statement.container, Do); 6 | let inputs: builtinValue[] = ( 7 | block.statements 8 | .filter(statement => statement.io === 'input') 9 | .map(statement => { 10 | statement.used = true; 11 | let input = assertDefined(statement.value); 12 | // convert _Number to number 13 | if (input instanceof _Number) { 14 | return input.value; 15 | } 16 | return input; 17 | }) 18 | ); 19 | assert(inputs.length > 0); 20 | return inputs; 21 | } 22 | 23 | /** evaluate a builtin call. Assumes input fields present in containing block. 24 | * Arguments have already been type-checked and assigned */ 25 | export function evalBuiltin(statement: Statement) { 26 | 27 | statement.used = true; 28 | let name = cast(statement.get('^builtin').value, Text).value; 29 | builtins[name](statement, ...inputs(statement)); 30 | } 31 | 32 | /** update a builtin call, returning result ^change */ 33 | export function updateBuiltin(statement: Statement, change: Item): Metafield { 34 | 35 | // get update function 36 | let name = cast(statement.get('^builtin').value, Text).value; 37 | let update = builtinUpdates[name]; 38 | if (!update) { 39 | throw new CompileError(statement, `Builtin %{name} not updatable`) 40 | } 41 | 42 | // write to ^delta of first input parameter 43 | let input = statement.container.items[0]; 44 | assert(input.io === 'input'); 45 | let write = input.setDelta(undefined); 46 | 47 | // append changed result to input values 48 | let values = inputs(statement); 49 | let delta = assertDefined(change.value); 50 | values.push( 51 | delta instanceof _Number ? delta.value : delta 52 | ); 53 | 54 | // call update function 55 | update(write, ...values); 56 | assert(write.value); 57 | 58 | return write; 59 | } 60 | 61 | /** builtins operate with JS number or Value */ 62 | export type builtinValue = number | Value; 63 | 64 | /** dispatch table for builtins */ 65 | export const builtins: Dictionary<(statement: Statement, ...args: any[]) => void> 66 | = {}; 67 | 68 | /** dispatch table for builtin updates. write is the ^delta to set. args are the 69 | * input argument values followed by the update value */ 70 | export const builtinUpdates: Dictionary<(write: Metafield, ...args: any[]) => void> 71 | = {}; 72 | 73 | /** definition of builtins */ 74 | export const builtinDefinitions = ` 75 | + = updatable{in: 0; plus: 0; builtin +} 76 | - = updatable{in: 0; subtrahend: 1; builtin -} 77 | * = updatable{in: 0; multiplicand: 2; builtin *} 78 | / = updatable{in: 0; divisor: 2; builtin /} 79 | truncate = do{in: 0; builtin truncate; export fraction = 0} 80 | >? = do{in: 0, than: 0, builtin >?} 81 | >=? = do{in: 0, than: 0, builtin >=?} 82 | { 95 | s.setFrom(a + b); 96 | } 97 | builtinUpdates['+'] = (write: Item, a: number, b: number, change: number) => { 98 | write.setFrom(change - b); 99 | } 100 | 101 | builtins['-'] = (s: Statement, a: number, b: number) => { 102 | s.setFrom(a - b); 103 | }; 104 | builtinUpdates['-'] = (write: Item, a: number, b: number, change: number) => { 105 | write.setFrom(change + b); 106 | } 107 | 108 | builtins['*'] = (s: Statement, a: number, b: number) => { 109 | s.setFrom(a * b); 110 | } 111 | builtinUpdates['*'] = (write: Item, a: number, b: number, change: number) => { 112 | write.setFrom(change / b); 113 | } 114 | 115 | builtins['/'] = (s: Statement, a: number, b: number) => { 116 | s.setFrom(a / b); 117 | } 118 | builtinUpdates['/'] = (write: Item, a: number, b: number, change: number) => { 119 | write.setFrom(change * b); 120 | } 121 | 122 | builtins['truncate'] = (s: Statement, a: number) => { 123 | s.setFrom(Math.trunc(a)); 124 | s.exportFrom(a - Math.trunc(a)); 125 | } 126 | 127 | builtins['>?'] = (s: Statement, a: number, b: number) => { 128 | s.setAccepted(a > b); 129 | s.setFrom(b); 130 | } 131 | builtins['>=?'] = (s: Statement, a: number, b: number) => { 132 | s.setAccepted(a >= b); 133 | s.setFrom(b); 134 | } 135 | builtins[' { 136 | s.setAccepted(a < b); 137 | s.setFrom(b); 138 | } 139 | builtins['<=?'] = (s: Statement, a: number, b: number) => { 140 | s.setAccepted(a <= b); 141 | s.setFrom(b); 142 | } 143 | builtins['=?'] = (s: Statement, a: builtinValue, b: builtinValue) => { 144 | // signature guarantees same types 145 | if (a instanceof Value) { 146 | assert(b instanceof Value); 147 | s.setAccepted(a.equals(b)) 148 | } else { 149 | s.setAccepted(a === b); 150 | } 151 | s.setFrom(b); 152 | } 153 | builtins['not=?'] = (s: Statement, a: builtinValue, b: builtinValue) => { 154 | // signature guarantees same types 155 | if (a instanceof Value) { 156 | assert(b instanceof Value); 157 | s.setAccepted(!a.equals(b)) 158 | } else { 159 | s.setAccepted(a !== b); 160 | } 161 | s.setFrom(b); 162 | } 163 | 164 | // TODO: Flip any binary choice. Needs a generic choice argument 165 | builtins['flip'] = (s: Statement, a: Value) => { 166 | s.setFrom(a); 167 | if (a instanceof Anything) return; 168 | if (!(a instanceof Choice) || a.fields.length !== 2) { 169 | throw new CompileError(a.token, 'Flip requires a binary choice' ) 170 | } 171 | let choice = cast(s.value, Choice); 172 | choice.setChoice(choice.choiceIndex? 0 : 1) 173 | } 174 | 175 | builtins['blank?'] = (s: Statement, a: builtinValue) => { 176 | // pass through source 177 | s.setFrom(a); 178 | if (a instanceof Value) { 179 | s.setAccepted(a.isBlank()) 180 | } else { 181 | s.setAccepted(Number.isNaN(a)); 182 | } 183 | } 184 | // TODO: not-blank? 185 | // TODO: to-blank -------------------------------------------------------------------------------- /src/choice.ts: -------------------------------------------------------------------------------- 1 | import { Block, trap, assert, CompileError, Path, Dictionary } from "./exports"; 2 | 3 | /** A Choice is a sum (discriminated union). It is a block where all the fields 4 | * (called options) are inputs, and exactly one of them is "chosen", meaning it 5 | * has a value and is accessible with a ?-guarded path */ 6 | export class Choice extends Block { 7 | 8 | /** 0-based index of choice. defaults to first choice */ 9 | choiceIndex: number = 0; 10 | 11 | get choice() { 12 | return this.fields[this.choiceIndex]; 13 | } 14 | 15 | /** set choice and initialize option value. Returns the option */ 16 | setChoice(index: number) { 17 | assert(index >= 0 && index < this.fields.length); 18 | this.choice.uneval(); 19 | this.choiceIndex = index; 20 | return this.fields[index]; 21 | } 22 | 23 | /** initialize to first value */ 24 | // uneval() { 25 | // this.setChoice(0); 26 | // } 27 | 28 | // Choice is blank if choosing first option which is blank 29 | isBlank() { 30 | return (this.choiceIndex === 0 && this.choice.value!.isBlank()) 31 | } 32 | 33 | /** evaluate */ 34 | eval() { 35 | if (!this.analyzing) { 36 | // evaluate choice 37 | this.choice.eval(); 38 | return; 39 | } 40 | // analyze all choices 41 | // validate option definitions 42 | this.fields.forEach(option => { 43 | assert(option.io === 'input' && option.conditional); 44 | }) 45 | // analyze first option 46 | this.fields[0].eval(); 47 | // defer analysis of rest of options to permit recursion 48 | this.fields.slice(1).forEach(option => { 49 | if (option.evaluated || option.deferral) { 50 | // skip options that are already evaluated or deferred 51 | return; 52 | } 53 | option.evaluated = undefined 54 | option.deferral = ( 55 | () => { option.eval(); } 56 | ) 57 | this.workspace.analysisQueue.push(option); 58 | }) 59 | } 60 | 61 | /** value equality */ 62 | equals(other: any) { 63 | return ( 64 | other instanceof Choice 65 | && this.choiceIndex == other.choiceIndex 66 | && this.choice.equals(other.choice) 67 | ) 68 | } 69 | 70 | copy(srcPath: Path, dstPath: Path): this { 71 | let to = super.copy(srcPath, dstPath); 72 | to.choiceIndex = this.choiceIndex; 73 | return to; 74 | } 75 | 76 | // dump as an object 77 | dump() { 78 | return { [this.choice.name!]: this.choice.dump() }; 79 | } 80 | } -------------------------------------------------------------------------------- /src/code.ts: -------------------------------------------------------------------------------- 1 | import { Block, Field, assert, CompileError, Guard, Path, cast, Reference, Token, assertDefined, another, arrayLast, Crash, trap, arrayReverse, Value, Record, Item } from "./exports"; 2 | 3 | /** A Code block is evaluated to produce a result value. The fields of the block 4 | * are called statements */ 5 | export class Code extends Block { 6 | 7 | get statements(): Statement[] { 8 | return this.items; 9 | } 10 | 11 | /** Statement with result value. Undefined before eval and after rejection. 12 | * Defined after analysis regardless of rejection */ 13 | result: Statement | undefined; 14 | 15 | /** field with exported value. Defined when result is */ 16 | export: Item | undefined; 17 | 18 | /** whether a field rejected */ 19 | rejected = false; 20 | 21 | /** whether evaluation is conditional */ 22 | conditional = false; 23 | 24 | /** Block can be updated from a call */ 25 | get callIsUpdatable(): boolean { 26 | return this instanceof Updatable || !!this.onUpdateBlock; 27 | } 28 | 29 | /** on-update block at end else undefined */ 30 | get onUpdateBlock(): OnUpdate | undefined { 31 | let block = arrayLast(this.statements).value; 32 | return block instanceof OnUpdate ? block : undefined 33 | } 34 | 35 | eval() { 36 | if (this.result) { 37 | return; 38 | } 39 | 40 | // set result as last statement, skipping backwards as needed 41 | // do this first to prevent deep value eval from recursing needlessly 42 | for (let statement of arrayReverse(this.statements)) { 43 | if (statement.dataflow) continue; 44 | statement.used = true; 45 | this.result = statement; 46 | break; 47 | } 48 | if (!this.result) { 49 | throw new CompileError(this.containingItem, 'code block has no result') 50 | } 51 | 52 | // evaluate statements until rejection 53 | let onUpdate = this.onUpdateBlock?.containingItem; 54 | for (let statement of this.statements) { 55 | statement.eval(); 56 | 57 | // first statement error propagates to block 58 | if (statement !== onUpdate) this.propagateError(statement); 59 | 60 | if (statement.conditional) { 61 | if (statement.inputLike) { 62 | throw new CompileError(statement, 'input fields cannot be conditional') 63 | } 64 | this.conditional = true; 65 | } 66 | 67 | // FIXME do ? naming check in Item.eval()? 68 | if (statement.rejected) { 69 | this.rejected = true 70 | // execute to completion during analysis 71 | // can't complete in all cases because of recursion 72 | if (this.analyzing) { 73 | continue; 74 | } 75 | this.result = undefined 76 | break; 77 | } 78 | } 79 | 80 | // eval exports 81 | this.evalExports(); 82 | } 83 | 84 | evalExports() { 85 | if (!this.result) return; 86 | let exports = this.statements.filter( 87 | statement => statement.dataflow === 'export' 88 | ); 89 | if (exports.length === 0) { 90 | // re-export result 91 | this.export = this.result.getMaybe('^export'); 92 | } else if (exports.length === 1 && exports[0].id.name === undefined) { 93 | // single anonymous export value 94 | this.export = exports[0]; 95 | } else { 96 | // assemble record out of export statements 97 | let record = new Record; 98 | // Create as ^export on code block 99 | // Adds an extra copy, but simplifies the API 100 | let meta = this.containingItem.getMaybe('^export'); 101 | if (meta) { 102 | meta.detachValue(); 103 | meta.setValue(record); 104 | } else { 105 | meta = this.containingItem.setMeta('^export', record); 106 | } 107 | this.export = meta; 108 | exports.forEach(ex => { 109 | if (ex.id.name === undefined) { 110 | throw new CompileError(ex, 'anonymous export statement must be unique') 111 | } 112 | const field = new Field; 113 | record.add(field); 114 | field.id = ex.id; 115 | field.io = 'input'; 116 | this.fieldImport(field, ex); 117 | }) 118 | } 119 | } 120 | 121 | 122 | /** define a field as a possibly recursive export */ 123 | // FIXME: recursive exports have turned out klugey. Redesign 124 | fieldImport(field: Field, ex: Item) { 125 | // detect recursive exports 126 | const exportType = ex.getMaybe('^exportType'); 127 | let exportOrigin = ex.value!.origin.containingItem; 128 | if ( 129 | exportOrigin instanceof Field 130 | && exportOrigin.id.name === '^export' 131 | ) { 132 | let originBase = exportOrigin.container.containingItem; 133 | if (originBase.containsOrEquals(this.containingItem) !== !!exportType) { 134 | throw new CompileError(ex, 'recursive export must define reference'); 135 | } 136 | } 137 | 138 | if (exportType) { 139 | // use supplied reference as type of export, needed for recursion 140 | // FIXME: infer from origin of exported value. Currently can only 141 | // detect recursion, not abstract it to clean reference 142 | field.formulaType = 'reference'; 143 | field.replaceMeta('^reference', exportType); 144 | field.copyValue(ex) 145 | 146 | // type check export 147 | if (this.analyzing) { 148 | this.workspace.exportAnalysisQueue.push(() => { 149 | let ref = cast(exportType.value, Reference); 150 | let target = assertDefined(ref.target); 151 | if (!target.value!.updatableFrom(ex.value!) 152 | ) { 153 | throw new CompileError(ref.tokens[0], 'changing type of value') 154 | } 155 | }) 156 | } 157 | } else { 158 | field.formulaType = 'none'; 159 | field.copyValue(ex) 160 | } 161 | } 162 | 163 | /** nested write statements */ 164 | writeStatements(): Statement[] { 165 | let writes: Statement[] = []; 166 | for (let statement of this.statements) { 167 | if (!statement.evaluated) continue; 168 | writes.push(...statement.writeStatements()); 169 | if (statement.formulaType === 'write') { 170 | writes.push(statement); 171 | } 172 | } 173 | return writes; 174 | } 175 | 176 | 177 | /** initialize all values */ 178 | uneval() { 179 | this.result = undefined; 180 | this.rejected = false; 181 | super.uneval(); 182 | } 183 | 184 | copy(srcPath: Path, dstPath: Path): this { 185 | let to = super.copy(srcPath, dstPath); 186 | to.conditional = this.conditional; 187 | return to; 188 | } 189 | } 190 | 191 | /** Statement is a field of a code block */ 192 | export class Statement extends Field { 193 | 194 | /** dataflow qualifier that passes previous value to next statement */ 195 | dataflow?: 'let' | 'check' | 'export' | 'on-update'; 196 | 197 | /** during analysis, whether field is used */ 198 | used?: boolean; 199 | 200 | /** called on builtin statement to set exported value */ 201 | exportFrom(value: string | number | Value) { 202 | let exportStatement = arrayLast(this.container.items); 203 | assert(exportStatement.dataflow === 'export'); 204 | exportStatement.detachValue(); 205 | exportStatement.setFrom(value); 206 | } 207 | 208 | /** called on conditional builtin statement to set whether accepted */ 209 | setAccepted(accepted: boolean) { 210 | this.rejected = !accepted; 211 | this.setConditional(true); 212 | } 213 | 214 | get name() { return this.id.name } 215 | 216 | copy(srcPath: Path, dstPath: Path): this { 217 | let to = super.copy(srcPath, dstPath); 218 | to.dataflow = this.dataflow; 219 | return to; 220 | } 221 | } 222 | 223 | /** A Do block is a procedure that evaluates statements sequentially without 224 | * using the previous value. Currently only Do blocks are callable */ 225 | export class Do extends Code { 226 | private _nominal: undefined; 227 | } 228 | 229 | /** A Do block whose calls can execute in reverse on update. 230 | * Cannot be recursive */ 231 | export class Updatable extends Do { 232 | private _nominal2: undefined; 233 | } 234 | 235 | /** A With block is a Do block that uses the previous value */ 236 | export class With extends Code { 237 | private _nominal: undefined; 238 | } 239 | 240 | /** A Call is a special Do block used to call a function. It contains a 241 | * reference to the function body followed by updates on the input arguments. 242 | * The final value is the function body modified with all supplied arguments. */ 243 | export class Call extends Code { 244 | 245 | /** whether call is asserting no rejections */ 246 | get asserted(): boolean { 247 | return this.token!.text.endsWith('!'); 248 | } 249 | 250 | /** Non-generic calls are short-circuited during analysis to allow 251 | * recursion. Only the inputs of the function are instantiated to analyze 252 | * arguments. The result is taken from the result of the definition. 253 | */ 254 | // Note edit errors in arguments are strictly passed to result. 255 | // Perhaps want to lazily pass through errors 256 | eval() { 257 | if (!this.analyzing) { 258 | // execute argument assignments 259 | super.eval(); 260 | if (!this.rejected) { 261 | // pull result out of final instance of code body 262 | let body = cast(arrayLast(this.statements).value, Code); 263 | body.eval(); 264 | this.result = body.result; 265 | if (body.result) this.propagateError(body.result); 266 | this.export = body.export; 267 | this.rejected = body.rejected; 268 | if (body.rejected && this.asserted) { 269 | throw new Crash(this.token, 'assertion failed') 270 | } 271 | } 272 | return; 273 | } 274 | 275 | // analyzing 276 | // first statement is ref to function definition 277 | let first = this.statements[0]; 278 | let ref = cast(first.get('^reference').value, Reference) 279 | ref.eval(); 280 | first.conditional = ref.conditional; 281 | 282 | // detect if code body is conditional 283 | let def = cast(ref.target!.value, Code); 284 | if (def.conditional && !this.asserted) { 285 | this.conditional = true; 286 | } 287 | 288 | // If generic or updatable call can't short-circuit, so can't recurse 289 | if (def.isGeneric || def.callIsUpdatable) { 290 | // execute call normally except detect conditionality 291 | super.eval(); 292 | let body = cast(arrayLast(this.fields).value, Code); 293 | body.eval(); 294 | this.result = body.result; 295 | this.export = body.export; 296 | this.rejected = body.rejected; 297 | // detect conditional arguments 298 | this.statements.slice(2).forEach(arg => { 299 | if (arg.io === 'input' && arg.conditional) this.conditional = true; 300 | }) 301 | return; 302 | } 303 | 304 | // copy just inputs of code 305 | let inputDefs = another(def); 306 | first.setValue(inputDefs); 307 | first.evaluated = true; 308 | def.fields.forEach(field => { 309 | if (field.io !== 'input') return; 310 | // copy context is entire definition 311 | inputDefs.add(field.copy(def.containingPath, first.path)) 312 | }) 313 | 314 | // analyze argument assignments 315 | this.statements.slice(1).forEach(arg => { 316 | arg.eval(); 317 | // detect if arguments might reject 318 | if (arg.conditional) this.conditional = true; 319 | }) 320 | 321 | // use result of definition 322 | this.result = assertDefined(def.result); 323 | this.propagateError(def.result!); 324 | this.export = def.export; 325 | } 326 | 327 | // suppress exporting 328 | evalExports() { } 329 | } 330 | 331 | /** update reaction blocks */ 332 | export class OnUpdate extends Code { 333 | 334 | eval() { 335 | 336 | super.eval(); 337 | 338 | // analyze update 339 | if (!this.analyzing) return; 340 | let container = this.containingItem.container; 341 | if ( 342 | !(container instanceof Code) 343 | || arrayLast(container.statements).value !== this 344 | ) { 345 | throw new CompileError(this.containingItem, 346 | 'on-update must be last statement of code block') 347 | } 348 | if (this.conditional) { 349 | throw new CompileError(this.containingItem, 350 | 'on-update cannot be conditional') 351 | } 352 | // generated input doesn't need to be used 353 | let input = this.statements[0]; 354 | input.used = true; 355 | // TODO type check user-defined input 356 | 357 | // force resolve() conditionals 358 | for (let item of this.containingItem.visit()) { 359 | item.resolve(); 360 | } 361 | } 362 | 363 | } -------------------------------------------------------------------------------- /src/container.ts: -------------------------------------------------------------------------------- 1 | import { assert, Item, Value, Path, MetaID } from "./exports"; 2 | 3 | /** superclass of Block and _Array, the two kinds of containers. */ 4 | export abstract class Container extends Value { 5 | 6 | /** array of items in proper order */ 7 | items: I[] = []; 8 | 9 | /** top-down iteration through all items */ 10 | *visit(): IterableIterator { 11 | for (let item of this.items) { 12 | yield* item.visit(); 13 | } 14 | } 15 | 16 | /** add an item to end */ 17 | add(item: I) { 18 | assert(!item.container); 19 | item.container = this; 20 | this.items.push(item); 21 | } 22 | 23 | /** unevaluate all items */ 24 | uneval() { 25 | this.items.forEach(item => item.uneval()); 26 | } 27 | 28 | 29 | /** make copy, bottom up, translating paths contextually */ 30 | copy(srcPath: Path, Path: Path): this { 31 | let to = super.copy(srcPath, Path); 32 | this.items.forEach(item => { 33 | assert(item.container === this); 34 | to.add(item.copy(srcPath, Path)); 35 | }) 36 | return to; 37 | } 38 | 39 | /** value equality */ 40 | equals(other: any) { 41 | return ( 42 | other instanceof Container 43 | && this.items.length === other.items.length 44 | && this.items.every( 45 | // TODO: only need to check input items 46 | (item, i) => item.equals(other.items[i]) 47 | ) 48 | ) 49 | } 50 | 51 | /** whether this value was transitively copied from another Value without any 52 | * updates */ 53 | isCopyOf(ancestor: this): boolean { 54 | return ( 55 | this.items.length === ancestor.items.length 56 | && this.items.every( 57 | (item, i) => { 58 | // ignore output items 59 | if (item.io === 'output') return true; 60 | let ancestorItem = ancestor.items[i]; 61 | return ( 62 | item.id === ancestorItem.id 63 | && item.value!.isCopyOf(ancestorItem.value!) 64 | ) 65 | } 66 | ) 67 | ) 68 | } 69 | } -------------------------------------------------------------------------------- /src/edit.ts: -------------------------------------------------------------------------------- 1 | import { Value } from "classnames"; 2 | import { ID, RealID, Field, Reference, trap, assert, Token, cast, arrayLast, Text, assertDefined, _Number, _Array, Entry, Record, FieldID, Version, Nil, Base, Character, Head, isNumber, CompileError, Item, Path, FormulaType } from "./exports"; 3 | 4 | /** evaluate edit operations */ 5 | export function edit(version: Version) { 6 | 7 | // get target path in input 8 | const targetRef = cast(version.get('^target').value, Reference); 9 | assert(targetRef.dependent); 10 | // get input version, which is context of reference 11 | const input = version.workspace.down( 12 | targetRef.path.ids.slice(0, targetRef.context) 13 | ); 14 | 15 | // copy input to result value, to be edited as needed 16 | version.copyValue(input); 17 | const head = cast(version.value, Head); 18 | const versionPathLength = version.path.length; 19 | 20 | // erase edit errors for re-analysis, but keep conversion errors 21 | for (let item of head.visit()) { 22 | if (item.originalEditError !== 'conversion') { 23 | item.editError = undefined; 24 | } 25 | } 26 | 27 | // get target within result 28 | const targetPath = targetRef.path.ids.slice(targetRef.context); 29 | const target = version.down(targetPath) 30 | assert(target); 31 | // FIXME: only editing data fields for now 32 | assert(target.io === 'data'); 33 | 34 | /** editor function to iterate */ 35 | let editor: (item: Item) => void; 36 | 37 | /** post-edit cleanup function */ 38 | // let cleanup = () => { return; }; 39 | 40 | /** Iterate edit over nested arrays */ 41 | function iterateEdit( 42 | context: Item, 43 | path: ID[], 44 | editor: (item: Item) => void 45 | ) { 46 | // detect if target is inside an array entry or template 47 | let index = path.findIndex(isNumber); 48 | if (index >= 0) { 49 | // recurse on array template and items 50 | // MAYBE: lift edit in entry to template 51 | assert(path[index] === 0); 52 | let array = context.down(path.slice(0, index)).value; 53 | assert(array instanceof _Array); 54 | let arrayPath = path.slice(index + 1); 55 | // recurse on rest of path into template 56 | iterateEdit(array.template, arrayPath, editor); 57 | // edit array entries too 58 | array.items.forEach(item => iterateEdit(item, arrayPath, editor)); 59 | return; 60 | } 61 | // edit target 62 | const target = context.down(path) 63 | editor(target); 64 | } 65 | 66 | /** copy from source to target. If target is inside an array entry, will copy 67 | * from its template instead, which should already have been copied from 68 | * source. Includes source metadata. Does not change target id or io. Returns 69 | * actual source of copy */ 70 | // FIXME is copying from template needed? create operation doesn't do that! 71 | // TODO theoretically this could be integrated into iterateEdit 72 | function templateCopy(target: Item, source: Item): Item { 73 | let templatePath = source.path; 74 | let entryPath = target.path; 75 | // find lowest array entry containing target 76 | let ids = target.path.ids.slice(versionPathLength).reverse(); 77 | let arrayIndex = ids.findIndex(id => isNumber(id) && id > 0); 78 | if (arrayIndex >= 0) { 79 | // copy from template, which should already have been copied from source 80 | let templateIds = target.path.ids.slice(); 81 | assert(templateIds[templateIds.length - 1 - arrayIndex] > 0); 82 | templateIds[templateIds.length - 1 - arrayIndex] = 0; 83 | source = target.workspace.down(templateIds); 84 | templatePath = source.path.up(arrayIndex); // path to template 85 | assert(arrayLast(templatePath.ids) === 0); 86 | entryPath = target.path.up(arrayIndex); // path to entry 87 | assert(arrayLast(entryPath.ids) > 0); 88 | } 89 | // create copy of source and metadata 90 | let temp = source.copy(templatePath, entryPath); 91 | // substitute into target 92 | target.metadata = temp.metadata; 93 | if (target.metadata) target.metadata.containingItem = target; 94 | target.formulaType = temp.formulaType; 95 | target.detachValueIf(); 96 | target.value = temp.value; 97 | if (target.value) target.value.containingItem = target; 98 | 99 | return source; 100 | } 101 | 102 | /** Disallow references outside literal value */ 103 | // TODO: could convert references to literal values 104 | function literalCheck(literal: Item) { 105 | for (let item of literal.visit()) { 106 | if (item.value instanceof Reference 107 | && !literal.contains(item.value.target!) 108 | ) { 109 | throw new CompileError(item, 'reference escaping literal value'); 110 | } 111 | } 112 | 113 | } 114 | 115 | switch (version.formulaType) { 116 | 117 | case '::nochange': { 118 | // no changes 119 | editor = () => { } 120 | break; 121 | } 122 | 123 | case '::replace': { 124 | // ^source is literal or reference 125 | let source = version.get('^source'); 126 | if (source.value instanceof Reference) { 127 | source = assertDefined(source.value.target); 128 | } else { 129 | literalCheck(source); 130 | } 131 | // function to perform edit 132 | editor = (target: Item) => { 133 | templateCopy(target, source); 134 | } 135 | break; 136 | } 137 | 138 | case '::append': { 139 | if (!(target.value instanceof Record)) { 140 | throw new CompileError(target, 'can only append to record') 141 | } 142 | // ^source is Record containing field to append/insert 143 | const sourceField = cast(version.get('^source').value, Record).fields[0]; 144 | assert(sourceField); 145 | let valueItem: Item = sourceField; 146 | if (valueItem.value instanceof Reference) { 147 | // use target of reference 148 | valueItem = assertDefined(valueItem.value.target); 149 | } else { 150 | literalCheck(valueItem); 151 | } 152 | 153 | // function to perform edit 154 | editor = (target: Item) => { 155 | let newField = new Field; 156 | cast(target.value, Record).add(newField); 157 | newField.id = sourceField.id; 158 | newField.io = sourceField.io; 159 | templateCopy(newField, valueItem); 160 | } 161 | break; 162 | } 163 | 164 | case '::insert': { 165 | if (!(target.container instanceof Record)) { 166 | throw new CompileError(target, 'can only insert into record') 167 | } 168 | // ^source is Record containing field to append/insert 169 | const sourceField = cast(version.get('^source').value, Record).fields[0]; 170 | assert(sourceField); 171 | let valueItem: Item = sourceField; 172 | if (valueItem.value instanceof Reference) { 173 | // use target of reference 174 | valueItem = assertDefined(valueItem.value.target); 175 | } else { 176 | literalCheck(valueItem); 177 | } 178 | 179 | // function to perform edit 180 | editor = (target: Item) => { 181 | let newField = new Field; 182 | newField.id = sourceField.id; 183 | newField.io = sourceField.io; 184 | let record = target.container as Record; 185 | newField.container = record; 186 | let i = record.fields.indexOf(target as Field); 187 | assert(i >= 0); 188 | record.fields.splice(i, 0, newField); 189 | templateCopy(newField, valueItem); 190 | } 191 | break; 192 | } 193 | 194 | case '::delete': { 195 | if (!(target instanceof Field)) { 196 | throw new CompileError(target, 'can only delete a field') 197 | } 198 | 199 | editor = (item) => (item as Field).delete(); 200 | break; 201 | } 202 | 203 | case '::move': 204 | case '::move-append': 205 | case '::move-insert': { 206 | // ^source is reference 207 | const sourceRef = cast(version.get('^source').value, Reference); 208 | if (!sourceRef.target) { 209 | throw new CompileError(undefined, 'invalid reference') 210 | } 211 | if (!(sourceRef.target instanceof Field)) { 212 | throw new CompileError(undefined, 'can only move a field') 213 | } 214 | 215 | // get source inside result 216 | assert(sourceRef.dependent); 217 | const x = version.down(sourceRef.path.ids.slice(sourceRef.context)); 218 | assert(x instanceof Field); 219 | const source = x; 220 | 221 | // Check that source and target in same template 222 | const lubLength = target.path.lub(source.path).length; 223 | if (target.path.ids.slice(lubLength).find(isNumber) 224 | || source.path.ids.slice(lubLength).find(isNumber) 225 | ) { 226 | // TODO: allow movement through arrays 227 | trap(); 228 | } 229 | // path from LUB to source 230 | const sourceSuffix = source.path.ids.slice(lubLength); 231 | 232 | let movedPath = target.path; 233 | 234 | /** perform move edit iterated over array entries by iteratedEdit */ 235 | function templateMove(to: Item, from: Item) { 236 | // copy from template or source 237 | let templateSource = templateCopy(to, from); 238 | 239 | if (templateSource === source) { 240 | // primary move 241 | 242 | // delete source 243 | source.delete(); 244 | 245 | // Add ^moved reference from source to target 246 | let moved = new Reference; 247 | moved.path = movedPath; 248 | // absolute reference in context of version 249 | moved.context = versionPathLength; 250 | moved.guards = target.path.ids.map(() => undefined); 251 | // Fake token array 252 | moved.tokens = target.path.ids.slice(moved.context).map(id => { 253 | let name = id.toString(); 254 | return new Token('name', 0, name.length, name); 255 | }) 256 | source.setMeta('^moved', moved); 257 | } else { 258 | // move within array entry 259 | 260 | // get copy of source in same entry 261 | let entrySource = source.workspace.down( 262 | [...to.path.ids.slice(0, lubLength), ...sourceSuffix]); 263 | assert(entrySource instanceof Field); 264 | 265 | // copy value from entry source 266 | to.detachValueIf(); 267 | if (entrySource.value) { 268 | to.copyValue(entrySource); 269 | } 270 | 271 | // delete source 272 | entrySource.delete(); 273 | 274 | // overwrite entry source from template source 275 | templateCopy(entrySource, source); 276 | let moved = assertDefined(entrySource.get('^moved')); 277 | moved.eval(); 278 | assert(cast(moved.value, Reference).target === to); 279 | } 280 | } 281 | 282 | if (version.formulaType === '::move') { 283 | // function to replace target 284 | editor = (target: Item) => { 285 | templateMove(target, source); 286 | target.io = source.io; 287 | } 288 | } else { 289 | // new ID allocated in parser follows that of this statement itself 290 | const newID = new FieldID(version.id.serial + 1); 291 | // use name of source 292 | newID.name = source.id.name; 293 | if (version.formulaType === '::move-append') { 294 | // append to record 295 | 296 | if (!(target.value instanceof Record)) { 297 | throw new CompileError(target, 'can only append to record') 298 | } 299 | movedPath = movedPath.down(newID); 300 | 301 | editor = (target: Item) => { 302 | let newField = new Field; 303 | newField.id = newID; 304 | newField.io = source.io; 305 | cast(target.value, Record).add(newField); 306 | templateMove(newField, source); 307 | } 308 | } else { 309 | // insert before field 310 | 311 | assert(version.formulaType === '::move-insert'); 312 | if (!(target.container instanceof Record)) { 313 | throw new CompileError(target, 'can only insert into record') 314 | } 315 | movedPath = movedPath.up(1).down(newID); 316 | 317 | editor = (target: Item) => { 318 | let newField = new Field; 319 | newField.id = newID; 320 | newField.io = source.io; 321 | let record = target.container as Record; 322 | newField.container = record; 323 | let i = record.fields.indexOf(target as Field); 324 | assert(i >= 0); 325 | record.fields.splice(i, 0, newField); 326 | templateMove(newField, source); 327 | } 328 | } 329 | } 330 | 331 | // Maybe: delete ^moved annotations after analysis 332 | // cleanup = () => { 333 | // for (let item of head.visit()) { 334 | // item.removeMeta('^moved'); 335 | // } 336 | // } 337 | 338 | break; 339 | } 340 | 341 | 342 | case '::wrap-record': 343 | case '::wrap-array': { 344 | const record = version.formulaType === '::wrap-record'; 345 | const source = target; 346 | 347 | let newID: RealID; 348 | if (record) { 349 | // new ID allocated in parser follows that of this statement itself 350 | newID = new FieldID(version.id.serial + 1); 351 | // FIXME: take name from a string argument? Or dup container name? 352 | newID.name = 'value'; 353 | } else { 354 | newID = 0; 355 | } 356 | 357 | // edit function 358 | // FIXME: doesn't copy from template in array entry. Would need a way to 359 | // extract current value without source links then overlay onto copy. 360 | // But note actual create operations don't copy from template either! 361 | editor = (target: Item) => { 362 | const movedPath = target.path.down(newID); 363 | // make copy of target 364 | let copy = target.copy(target.path, movedPath); 365 | let item: Item; 366 | if (record) { 367 | // convert to Field 368 | item = new Field; 369 | item.io = copy.io; 370 | item.formulaType = copy.formulaType; 371 | } else { 372 | // convert to template 373 | item = new Entry; 374 | // FIXME what if item is wrong mode for a template? 375 | assert(target.dataLike && target.formulaType === 'none'); 376 | item.io = 'data'; 377 | item.formulaType = 'none'; 378 | } 379 | item.id = newID; 380 | item.conditional = copy.conditional; 381 | item.editError = copy.editError; 382 | 383 | item.metadata = copy.metadata; 384 | if (item.metadata) item.metadata.containingItem = item; 385 | item.value = copy.value; 386 | if (item.value) item.value.containingItem = item; 387 | 388 | // replace target with wrapped value 389 | target.detachValueIf(); 390 | if (record) { 391 | // wrap in record 392 | let rec = new Record; 393 | rec.add(item as Field); 394 | target.setValue(rec); 395 | } else { 396 | // wrap in array 397 | let array = new _Array; 398 | array.template = item as Entry; 399 | item.container = array; 400 | target.setValue(array); 401 | // Create single entry 402 | let entry = array.createEntry(); 403 | entry.copyValue(item); 404 | if (target !== source) { 405 | // set template value from original source location so make multiple 406 | // template instances have same value 407 | item.detachValue(); 408 | item.copyValue((source.value as _Array).template); 409 | } 410 | } 411 | 412 | // reformulate target as data 413 | target.metadata = undefined; 414 | target.io = 'data'; 415 | target.formulaType = 'none'; 416 | target.conditional = false; 417 | target.usesPrevious = false; 418 | target.editError = undefined; 419 | } 420 | break; 421 | } 422 | 423 | 424 | case '::unwrap': { 425 | 426 | // Replace a record with its only field 427 | // Replace an array with its only entry 428 | // Conversion error otherwise 429 | 430 | editor = (target: Item) => { 431 | let items: Item[]; 432 | let value = target.value; 433 | if (value instanceof Record) { 434 | items = value.fields.filter(field => !field.deleted); 435 | } else if (value instanceof _Array) { 436 | items = value.items; 437 | } else { 438 | // invalid type 439 | trap(); 440 | } 441 | if (items.length !== 1) { 442 | // conversion error if not exactly one item 443 | target.editError = 'conversion'; 444 | } 445 | let item = items[0]; 446 | if (!item) { 447 | if (value instanceof _Array) { 448 | // empty array - convert to template value 449 | item = value.template; 450 | } else { 451 | // no record fields - convert to Nil 452 | target.detachValueIf(); 453 | target.setValue(new Nil); 454 | target.metadata = undefined; 455 | target.formulaType = 'none'; 456 | return; 457 | } 458 | } 459 | // make copy of item and patch into target 460 | // carries metadata with it 461 | let copy = item.copy(item.path, target.path); 462 | target.detachValueIf(); 463 | if (copy.value) { 464 | target.value = copy.value; 465 | target.value.containingItem = target; 466 | } 467 | target.metadata = copy.metadata; 468 | if (target.metadata) { 469 | target.metadata.containingItem = target; 470 | } 471 | target.io = copy.io; 472 | target.formulaType = copy.formulaType; 473 | return; 474 | } 475 | break; 476 | } 477 | 478 | 479 | case '::convert': { 480 | // ^source is literal or reference 481 | const to = version.get('^source'); 482 | literalCheck(to); 483 | const toType = assertDefined(to.value); 484 | if (!(toType instanceof Base || toType instanceof Text)) { 485 | throw new CompileError(to, 'can only convert to base types and text'); 486 | } 487 | 488 | // function to perform edit 489 | editor = (target: Item) => { 490 | let fromVal = assertDefined(target.value); 491 | target.detachValue(); 492 | target.editError = undefined; 493 | if (toType instanceof Reference) trap(); 494 | if (toType instanceof Nil) { 495 | // anything can be converted to Nil 496 | target.setValue(new Nil); 497 | return; 498 | } 499 | if (fromVal instanceof Nil) { 500 | // Nil gets converted to value of to type 501 | target.copyValue(to); 502 | return; 503 | } 504 | 505 | // convert to text 506 | if (toType instanceof Text) { 507 | if (fromVal instanceof Text) { 508 | // noop 509 | target.setValue(fromVal); 510 | return; 511 | } 512 | 513 | // default to value of toType 514 | let text = new Text; 515 | target.setValue(text); 516 | text.value = toType.value 517 | 518 | if (fromVal instanceof _Number) { 519 | // number to text 520 | if (fromVal.isBlank()) { 521 | // NaN converts to value of toType 522 | // FIXME - should it be blank value? 523 | } else { 524 | // use standard JS numnber to string conversion 525 | text.value = fromVal.value.toString(); 526 | } 527 | return; 528 | } 529 | 530 | if (fromVal instanceof Character) { 531 | text.value = fromVal.value; 532 | return; 533 | } 534 | 535 | // other types convert to toType value 536 | return; 537 | } 538 | 539 | // convert to number 540 | if (toType instanceof _Number) { 541 | if (fromVal instanceof _Number) { 542 | // noop 543 | target.setValue(fromVal); 544 | return; 545 | } 546 | let num = new _Number; 547 | target.setValue(num); 548 | 549 | if (fromVal instanceof Character) { 550 | num.value = fromVal.value.charCodeAt(0); 551 | return; 552 | } 553 | if (fromVal instanceof Text) { 554 | // convert text to number, which may fail 555 | let text = fromVal.value.trim(); 556 | if (text) { 557 | num.value = Number(text); 558 | if (Number.isNaN(num.value)) { 559 | // conversion error 560 | target.editError = 'conversion'; 561 | } 562 | } else { 563 | // convert empty/whitespace text to NaN 564 | num.value = Number.NaN; 565 | } 566 | return; 567 | } 568 | 569 | // other types convert to toType value 570 | num.value = toType.value; 571 | return; 572 | } 573 | 574 | // unrecognized conversion type 575 | trap(); 576 | } 577 | 578 | break; 579 | } 580 | 581 | 582 | default: 583 | trap(); 584 | } 585 | 586 | // iterate edit into arrays 587 | iterateEdit(version, targetPath, editor) 588 | 589 | // analyze results of edit, performing reference forwarding 590 | version.workspace.analyze(version); 591 | 592 | // perform cleanups 593 | // cleanup(); 594 | } 595 | 596 | /* 597 | -------------------------------------------------------------------------------- 598 | edit transformation 599 | */ 600 | 601 | /** Abstract edit description. Pulled out of a Version, with the target relative 602 | * reference converted to a Path */ 603 | type Edit = { 604 | /** path at which to apply edit */ 605 | target: Path, 606 | /** type of edit */ 607 | type: FormulaType, 608 | /** source of some edits, either a literal base value or a path */ 609 | source?: Base | Path 610 | } 611 | 612 | /** pull an edit through another one, where they both apply to the same base 613 | * state. Produces 2 new edits such that this diagram commutes: 614 | * 615 | * branch 616 | * o------------->o 617 | * | | 618 | * |delta |newDelta 619 | * | | 620 | * v v 621 | * o------------->o 622 | * newBranch 623 | * 624 | * */ 625 | export function pullEdit(delta: Edit, branch: Edit): 626 | { newDelta: Edit, newBranch: Edit } { 627 | 628 | // nochange propagates 629 | if (delta.type === '::nochange' || branch.type === '::nochange') { 630 | return { newDelta: delta, newBranch: branch }; 631 | } 632 | 633 | // equal edits cancel out into nochange 634 | if ( 635 | delta.target.equals(branch.target) 636 | && delta.type === branch.type 637 | && ( 638 | (delta.source === undefined && branch.source === undefined) 639 | || ( 640 | delta.source instanceof Path 641 | && branch.source instanceof Path 642 | && delta.source.equals(branch.source)) 643 | || ( 644 | delta.source instanceof Base 645 | && delta.source.equals(branch.source)) 646 | )) 647 | { 648 | let nochange: Edit = { target: delta.target, type: '::nochange' }; 649 | return { newDelta: nochange, newBranch: nochange }; 650 | } 651 | 652 | 653 | trap(); 654 | } -------------------------------------------------------------------------------- /src/exports.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module exports 3 | * 4 | * This module controls module loading order to break cycles 5 | * Thanks to https://medium.com/visual-development/how-to-fix-nasty-circular-dependency-issues-once-and-for-all-in-javascript-typescript-a04c987cf0de 6 | * 7 | * Doesn't work with create-react-app's webpack config 8 | * 9 | * There are also reports that webpack tree-shaking breaks this 10 | */ 11 | export * from "./util"; 12 | export * from "./tokenize"; 13 | export * from "./parser"; 14 | export * from "./path"; 15 | export * from "./item"; 16 | export * from "./value"; 17 | export * from "./base"; 18 | export * from "./reference"; 19 | export * from "./container"; 20 | export * from "./block"; 21 | export * from "./metadata"; 22 | export * from "./head"; 23 | export * from "./history"; 24 | export * from "./code"; 25 | export * from "./try"; 26 | export * from "./choice"; 27 | export * from "./workspace"; 28 | import { Workspace } from "./workspace"; 29 | export * from "./builtins"; 30 | import { builtinDefinitions } from "./builtins"; 31 | import { arrayBuiltinDefinitions } from "./array"; 32 | export * from "./array"; 33 | export { edit } from "./edit"; 34 | 35 | /** builtin workspace to be included into other workspaces */ 36 | export const builtinWorkspace = Workspace.compile( 37 | builtinDefinitions + arrayBuiltinDefinitions, 38 | false 39 | ); -------------------------------------------------------------------------------- /src/head.ts: -------------------------------------------------------------------------------- 1 | import { Block, Record } from "./exports"; 2 | 3 | /** A Head is the top program-visible value of a workspace. Heads are contained 4 | * in the History block. The first field of the head imports the builtins */ 5 | export class Head extends Record { 6 | 7 | 8 | } 9 | -------------------------------------------------------------------------------- /src/history.ts: -------------------------------------------------------------------------------- 1 | import { Block, FieldID, Head, Field, arrayLast, Item, Workspace } from "./exports"; 2 | 3 | /** History is the top value of a Space. The History is a Block whose fields 4 | * have a VersionID and contain a Head */ 5 | export class History extends Block { 6 | declare containingItem: Workspace; 7 | get workspace() { return this.containingItem; } 8 | 9 | get versions() { 10 | return this.items; 11 | } 12 | 13 | get currentVersion(): Version { 14 | return arrayLast(this.versions); 15 | } 16 | 17 | // dump current version 18 | dump() { return this.currentVersion.dump()} 19 | } 20 | 21 | /** Version is a Field with a VersionID and containing a Head */ 22 | export class Version extends Field { 23 | _version = this; 24 | 25 | // versions are all outputs 26 | isInput = false; 27 | 28 | /** array of items with edit errors */ 29 | get editErrors(): Item[] { 30 | let errors: Item[] = []; 31 | for (let item of this.visit()) { 32 | if (item.editError) { 33 | errors.push(item); 34 | } 35 | } 36 | return errors; 37 | } 38 | 39 | /** Array of edit error messages */ 40 | get editErrorMessages(): string[] { 41 | return (this.editErrors 42 | .map(item => item.path.dumpInVersion() + ': ' + item.originalEditError) 43 | ) 44 | } 45 | } 46 | 47 | /** space-unique ID of a Version */ 48 | export class VersionID extends FieldID { 49 | } 50 | 51 | -------------------------------------------------------------------------------- /src/metadata.ts: -------------------------------------------------------------------------------- 1 | import { Block, FieldID, Item, Value, Dictionary, assert, assertDefined, Field, Reference, cast, Text, Choice, CompileError, ID, OptionReference, Path } from "./exports"; 2 | 3 | /** Metadata on an Item, containing fields with MetafieldID, and whose names all 4 | *start with '^' */ 5 | export class Metadata extends Block { 6 | 7 | /** logical container is base item's logical container */ 8 | get up(): Item { 9 | return this.containingItem.up; 10 | } 11 | 12 | /** sets a metadata field, which must not already exist. Value must be 13 | * detached or undefined. */ 14 | set(name: string, value?: Value): Metafield { 15 | let id = assertDefined(MetaID.ids[name]); 16 | assert(!this.getMaybe(id)); 17 | let field = new Metafield; 18 | this.fields.push(field) 19 | field.container = this; 20 | field.id = id; 21 | // define as literal output field (a constant) 22 | field.io = 'output'; 23 | field.formulaType = 'none'; 24 | if (value) { 25 | field.setValue(value); 26 | } 27 | 28 | return field; 29 | } 30 | } 31 | 32 | /** Special Metadata to contain ^delta Metafield, which is not stored in normal 33 | * metadata. Stored in Item.delta*/ 34 | export class DeltaContainer extends Metadata { 35 | get deltaField() { return this.items[0] } 36 | } 37 | 38 | 39 | export class Metafield extends Field { 40 | 41 | /** base field */ 42 | get base(): Item { 43 | return this.container.containingItem; 44 | } 45 | 46 | /** Previous item in metadata is previous item of the base data. Except 47 | * ^payload goes to ^target */ 48 | previous(): Item | undefined { 49 | this.usesPrevious = true; 50 | if (this.id === MetaID.ids['^payload']) { 51 | // previous value of payload is the target 52 | let ref = cast( 53 | this.container.get('^target').value, 54 | Reference); 55 | // should already have been dereferenced 56 | let target = assertDefined(ref.target); 57 | if (ref instanceof OptionReference) { 58 | // get initial value of option 59 | assert(ref.optionID); 60 | let option = target.get(ref.optionID); 61 | assert(option.container instanceof Choice); 62 | // use initial value of option, not current value 63 | return option.get('^initial'); 64 | } 65 | return target; 66 | } 67 | // previous value of base item 68 | return this.container.containingItem.previous(); 69 | } 70 | } 71 | 72 | /** Globally-unique ID of a MetaField. Name starts with '^'. Immutable and 73 | * interned. */ 74 | export class MetaID extends FieldID { 75 | // MetaID doesn't use a serial #. Instead the name is the globally unique ID. 76 | private constructor(name: string) { 77 | super(NaN); 78 | this.name = name; 79 | } 80 | 81 | private static define(names: string[]): Dictionary { 82 | let dict: Dictionary = {}; 83 | names.forEach(name => { 84 | dict[name] = new MetaID(name) 85 | }) 86 | return dict; 87 | } 88 | static ids: Dictionary = MetaID.define([ 89 | '^literal', // Literal formula 90 | '^reference', // Reference formula 91 | '^code', // Code block 92 | '^loop', // Loop block 93 | '^target', // target reference of := & -> & ::edits 94 | '^payload', // Formula after := 95 | '^source', // Value in edit 96 | '^call', // function call 97 | '^builtin', // builtin call 98 | '^initial', // initial value of item 99 | '^export', // Exported value 100 | '^exportType', // Exported value type 101 | '^writeValue', // Formula before -> 102 | '^delta', // Pending change in DeltaContainer 103 | '^extend', // record extension 104 | '^any', // generic selection 105 | '^moved', // reference to move destination 106 | ]); 107 | } -------------------------------------------------------------------------------- /src/path.ts: -------------------------------------------------------------------------------- 1 | import { MetaID, VersionID, assert, FieldID } from "./exports"; 2 | 3 | /** 4 | * ID of an item. Immutable and interned, so can use === 5 | * Arrays use plain JS numbers, either ordinals or serial numbers. 6 | * Strings are used as a convenience in APIs but not used as Item id 7 | */ 8 | export type RealID = FieldID | number; 9 | export type ID = RealID | string; 10 | 11 | /** Path down into the Space. A sequence of IDs starting with a VersionID. 12 | * Immutable after construction */ 13 | export class Path { 14 | 15 | readonly ids: ReadonlyArray; 16 | get length() { return this.ids.length } 17 | 18 | constructor(ids: ReadonlyArray) { 19 | assert(!ids.length || ids[0] instanceof VersionID); 20 | this.ids = ids; 21 | } 22 | 23 | static readonly empty = new Path([]); 24 | 25 | /** extend path downward */ 26 | down(id: ID): Path { 27 | return new Path([...this.ids, id]); 28 | } 29 | 30 | /** lift path upwards */ 31 | up(n: number): Path { 32 | if (n === 0) return this; 33 | return new Path(this.ids.slice(0, -n)); 34 | } 35 | 36 | /** path equality */ 37 | equals(other: Path) { 38 | return ( 39 | this.ids.length === other.ids.length 40 | && this.ids.every((id, i) => id === other.ids[i])); 41 | } 42 | 43 | /** whether other path equals or extends this path. Considers metadata to 44 | * extend the base data */ 45 | extendedBy(other: Path): boolean { 46 | return ( 47 | this.length <= other.length 48 | && this.ids.every((id, i) => id === other.ids[i]) 49 | ) 50 | } 51 | 52 | /** Whether other path is within but not equal to this path */ 53 | contains(other: Path): boolean { 54 | return ( 55 | // must be longer 56 | other.length > this.length 57 | // not in our metadata 58 | && !(other.ids[this.length] instanceof MetaID) 59 | // must extend this path 60 | && this.ids.every((id, i) => id === other.ids[i]) 61 | ); 62 | } 63 | 64 | containsOrEquals(other: Path): boolean { 65 | return this.equals(other) || this.contains(other); 66 | } 67 | 68 | /** Whether other path is in our metadata */ 69 | metadataContains(other: Path): boolean { 70 | return ( 71 | // must be longer 72 | other.length > this.length 73 | // in our metadata 74 | && other.ids[this.length] instanceof MetaID 75 | // must extend this path 76 | && this.ids.every((id, i) => id === other.ids[i]) 77 | ); 78 | } 79 | 80 | /** returns least upper bound with another path, which is their shared prefix 81 | * */ 82 | lub(other: Path): Path { 83 | // where the paths diverge 84 | let end = this.ids.findIndex( 85 | (id, i) => i >= other.ids.length || id !== other.ids[i] 86 | ); 87 | if (end < 0) { 88 | return this; 89 | } else { 90 | return new Path(other.ids.slice(0, end)); 91 | } 92 | } 93 | 94 | /** 95 | * Path translation. Paths within a deep copy get translated, so that any path 96 | * contained in the source of the copy gets translated into a path contained in 97 | * the destination. Relative paths are not translated. 98 | */ 99 | translate(src: Path, dst: Path): Path { 100 | if (src.contains(this)) { 101 | // translate from src to dst 102 | return new Path([...dst.ids, ...this.ids.slice(src.length)]); 103 | } 104 | // disallow destination from "capturing" path outside src into dst 105 | // this should be caught during static analysis as a cyclic reference 106 | assert(!dst.contains(this)); 107 | return this; 108 | } 109 | 110 | toString() { return this.dump() }; 111 | 112 | // dump path as dotted string 113 | dump() { return this.ids.join('.'); } 114 | 115 | // dump path within version as a dotted string 116 | dumpInVersion() { return this.ids.slice(1).join('.'); } 117 | } -------------------------------------------------------------------------------- /src/reference.ts: -------------------------------------------------------------------------------- 1 | import { arrayEquals, Token, Path, Item, assert, MetaID, trap, Block, CompileError, ID, arrayLast, another, Value, cast, Call, Do, Code, Crash, Statement, Choice, _Array, FieldID, Selection, Field } from "./exports"; 2 | 3 | /** Guard on an ID in a reference */ 4 | export type Guard = '?' | '!' | undefined; 5 | 6 | /** Reference to an item at a path in the workspace 7 | * 8 | * References are either structural or dependent. A structural reference is to 9 | * another item within a container of the base item. The syntax of a structural 10 | * reference starts with a field name, which is lexically bound. A dependent 11 | * reference is a path within the previous value of the base item. The 12 | * structural path to the previous item is prefixed to a dependent reference. 13 | * The syntax of a dependent reference starts with a '.', or '~'. 14 | */ 15 | export class Reference extends Value { 16 | 17 | /** Tokens of path in source. May have leading 'that' token. Name tokens have 18 | * leading ^/~ and trailing ?/!. Special tokens used for binding calls: call, 19 | * arg1, arg2, input. Number tokens are used for testing */ 20 | // FIXME: this is inappropriate for references generated by editing 21 | tokens!: Token[]; 22 | 23 | /** reference is dependent if starts with 'that' */ 24 | get dependent() { return this.tokens[0].type === 'that' } 25 | 26 | 27 | /** Path to follow */ 28 | path!: Path; 29 | 30 | /** index in path to end of contextual part of path. In a structural 31 | * reference, the context is the LUB container of the base and target items. 32 | * In a dependent reference, the context is the path of the previous value. 33 | * The contextual part of the path is unguarded, and we avoid evaluating it to 34 | * prevent evaluation cycles. 35 | * */ 36 | context!: number; 37 | 38 | /** guards for each ID in path. Context is unguarded */ 39 | // TODO: only record context part of path? 40 | guards!: Guard[]; 41 | 42 | /** pointer to target item of reference (not a copy). Derived during eval, not 43 | * copied. undefined on reference editError */ 44 | target?: Item; 45 | 46 | /** whether reference is conditional within path */ 47 | conditional = false; 48 | 49 | /** whether reference was rejected. Derived, not copied */ 50 | rejected = false; 51 | 52 | // references are never blank 53 | isBlank() { return false; } 54 | 55 | /** Evaluate reference */ 56 | eval() { 57 | if (this.target || this.rejected || this.editError === 'reference') { 58 | // already evaluated 59 | return; 60 | } 61 | 62 | // Naked References only exist in metadata 63 | assert(this.id instanceof MetaID || this instanceof Selection); 64 | // Reference is dependent to base item 65 | let from = this.containingItem.container.containingItem; 66 | 67 | if (!this.path) { 68 | // bind path 69 | this.bind(from); 70 | } 71 | 72 | // dereference 73 | const basePath = this.containingPath; 74 | let target: Item = from.workspace; 75 | for (let i = 0; i < this.path.ids.length; i++) { 76 | let id = this.path.ids[i]; 77 | // follow paths past rejection during analysis 78 | if (this.rejected && !this.analyzing) continue; 79 | 80 | let down = target.getMaybe(id); 81 | let moved = down?.getMaybe('^moved') 82 | if (moved) { 83 | // forward moved reference 84 | assert(this.analyzing); 85 | let ref = cast(moved.value, Reference) 86 | let movedPath = ref.target!.path; 87 | if (this.dependent) { 88 | if (i >= this.context) { 89 | // move within context requires that target remain within context 90 | let context = new Path(this.path.ids.slice(0, this.context)); 91 | if (!context.containsOrEquals(movedPath)) { 92 | // dangling reference error 93 | this.containingItem.editError = 'reference'; 94 | return; 95 | } 96 | } else { 97 | // adjust size of moved context 98 | this.context = this.context - i - 1 + movedPath.length; 99 | } 100 | } else { 101 | // structural reference 102 | // context is LUB of reference location with target 103 | this.context = movedPath.lub(basePath).length; 104 | } 105 | 106 | // move path 107 | this.path = new Path( 108 | [...movedPath.ids, ...this.path.ids.slice(i + 1)]); 109 | this.guards = 110 | [...movedPath.ids.map(() => undefined), 111 | ...this.guards.slice(i + 1)]; 112 | // FIXME guard path down from LUB to moved location? 113 | 114 | // eval moved reference 115 | this.eval(); 116 | return; 117 | } 118 | 119 | if (down instanceof Field && down.deleted) { 120 | // unmoved deleted target is error 121 | down = undefined; 122 | } 123 | 124 | if (!down) { 125 | // dereference error - can only occur during editing 126 | this.containingItem.editError = 'reference'; 127 | assert(!this.containingItem.isDerived); 128 | this.target = undefined; 129 | return; 130 | } 131 | 132 | target = down; 133 | let next = this.path.ids[i + 1]; 134 | if ( next instanceof MetaID && next !== MetaID.ids['^export']) { 135 | // metadata is not inside base item, so skip evaluating it 136 | continue; 137 | } 138 | 139 | // mark code statements used 140 | if (target instanceof Statement) { 141 | target.used = true; 142 | } 143 | 144 | // evaluate on way down if needed 145 | this.evalIfNeeded(target); 146 | 147 | // check guards within context 148 | if (i >= this.context) { 149 | let guard = this.guards[i]; 150 | assert(!!guard === target.conditional); 151 | if ( 152 | target.rejected || ( 153 | target.container instanceof Choice 154 | && target !== target.container.choice 155 | ) 156 | ) { 157 | // reject reference 158 | this.rejected = true; 159 | if (!this.analyzing && guard === '!') { 160 | throw new Crash(this.tokens[i - this.context], 'assertion failed') 161 | } 162 | } 163 | } 164 | } 165 | 166 | // evaluate final target deeply 167 | // Not needed on Selections, which lazily access their target contents 168 | if (!(this instanceof Selection)) { 169 | target.eval(); 170 | } 171 | 172 | this.target = target; 173 | } 174 | 175 | /** evaluate item if needed to dereference, to avoid eager deep eval */ 176 | evalIfNeeded(item: Item) { 177 | if (item.editError === 'reference') return; 178 | if (!item.value && !item.rejected) { 179 | item.eval(); 180 | } 181 | // resolve deferred item 182 | item.resolve(); 183 | } 184 | 185 | // bind reference during analysis 186 | bind(from: Item) { 187 | const inHistoryFormula = this.containingItem.inHistoryFormula; 188 | assert(this.analyzing || inHistoryFormula); 189 | assert(this.tokens && this.tokens.length); 190 | 191 | // strip out guards from names 192 | let tokenNames: string[] = []; 193 | let tokenGuards: Guard[] = []; 194 | this.tokens.forEach(token => { 195 | let suffix = token.text.slice(-1); 196 | switch (suffix) { 197 | case '?': 198 | case '!': 199 | tokenGuards.push(suffix); 200 | tokenNames.push(token.text.slice(0, -1)); 201 | return; 202 | default: 203 | tokenGuards.push(undefined); 204 | tokenNames.push(token.text); 205 | return; 206 | } 207 | }); 208 | 209 | let target: Item | undefined; 210 | 211 | if (this.tokens[0].type === 'input') { 212 | /** bind to input of a call. 213 | * This occurs on ^payload^reference of Call. 214 | * Possibly could generalize to outer-that. 215 | * */ 216 | assert(this.tokens.length === 1); 217 | assert(from.id.toString() === '^payload'); 218 | let update = from.container.containingItem; 219 | assert(update.formulaType === 'updateInput'); 220 | assert(update.container instanceof Call); 221 | let call = update.container.containingItem; 222 | assert(call.id.toString() === '^call'); 223 | target = this.previous(call, this.tokens[0]); 224 | if (target.evaluated === undefined) { 225 | throw new CompileError(this.tokens[0], 'Circular reference') 226 | } 227 | this.path = target.path; 228 | this.context = this.path.length; 229 | this.guards = this.path.ids.map(() => undefined); 230 | return; 231 | } 232 | 233 | if (this.dependent) { 234 | 235 | // bind dependent reference within previous value 236 | target = this.previous(from, this.tokens[0]); 237 | } else { 238 | /** bind first name of structural reference lexically by searching upward 239 | * to match first name. Note upwards scan skips from metadata to base 240 | * item's container. Also looks one level into includes */ 241 | // TODO: bind downwards through records in includes 242 | let first = tokenNames[0]; 243 | lexicalBinding: for (let up of from.upwards()) { 244 | if (up.value instanceof Block) { 245 | for (let field of (up.value as Block).fields) { 246 | if (field.name === first) { 247 | target = up; 248 | break lexicalBinding; 249 | } 250 | // bind against included fields too 251 | if (field.formulaType === 'include') { 252 | assert(field.value instanceof Block); 253 | for (let included of (field.value as Block).fields) { 254 | if (included.name === first) { 255 | target = field; 256 | break lexicalBinding; 257 | } 258 | } 259 | } 260 | } 261 | } 262 | } 263 | if (!target && inHistoryFormula) { 264 | // at toplevel bind against initial builtins 265 | target = this.workspace.down('initial.builtins'); 266 | } 267 | if (!target) { 268 | // hit top without binding 269 | throw new CompileError(this.tokens[0], 'Undefined name') 270 | } 271 | } 272 | 273 | // target is context of path, which is unguarded 274 | let ids: ID[] = target.path.ids.slice(); 275 | let guards: Guard[] = ids.map(() => undefined); 276 | this.context = ids.length; 277 | 278 | // follow path downwards from context, advancing target 279 | for (let i = 0; i < this.tokens.length; i++) { 280 | let token = this.tokens[i]; 281 | let type = token.type; 282 | let name = tokenNames[i]; 283 | let guard = tokenGuards[i]; 284 | 285 | if (type === 'that') { 286 | 287 | // skip leading that in dependent path 288 | assert(i === 0 && this.dependent); 289 | continue; 290 | } else if (name[0] === '^') { 291 | 292 | // don't evaluate base item on path into metadata 293 | if (tokenGuards[i - 1] !== undefined) { 294 | throw new CompileError( 295 | this.tokens[i - 1], 296 | 'No guard allowed before ^' 297 | ); 298 | } 299 | } else if (type === 'call') { 300 | 301 | if (target.evaluated === undefined) { 302 | // illegal recursion 303 | // FIXME - appears now caught by evaling reference 304 | trap(); 305 | // throw new Crash( 306 | // this.tokens[i - 1], 307 | // 'recursion outside secondary try clause' 308 | // ); 309 | } 310 | 311 | // dereferences formula body in a call 312 | let call = target.getMaybe('^code'); 313 | if (!call || !(call.value instanceof Do)) { 314 | throw new CompileError(token, 'Can only call a do-block'); 315 | } 316 | 317 | // erase guard from base field. Call.guard will check it 318 | // note conditional access will have been checked on the base field 319 | guards.pop(); 320 | guards.push(undefined); 321 | // fall through to name lookup 322 | name = '^code'; 323 | } else { 324 | 325 | // follow path into value 326 | // evaluate target if needed 327 | this.evalIfNeeded(target); 328 | if (!target.value) { 329 | // cyclic dependency 330 | // FIXME - Don't think this can happen anymore 331 | trap(); 332 | // throw new StaticError(token, 'Circular reference') 333 | } 334 | if (name === '~') { 335 | // exports are in metadata 336 | name = '^export'; 337 | } 338 | } 339 | 340 | if (type === 'arg1' || type === 'arg2') { 341 | 342 | // positional call argument 343 | target = ( 344 | target.value instanceof Do 345 | ? target.value.fields[type === 'arg1' ? 0 : 1] 346 | : undefined 347 | ) 348 | if (target?.io !== 'input') { 349 | throw new CompileError(token, 'function input not defined') 350 | } 351 | } else { 352 | 353 | // dereference by name 354 | //let prevTarget = target; // for debugging 355 | if (name === '[]') { 356 | // template access 357 | if (target.value instanceof _Array) { 358 | target = target.value.template; 359 | } else if (target.value instanceof Selection) { 360 | // template of backing array of selection 361 | target = target.value.backing.template 362 | } else { 363 | throw new CompileError(token, 364 | '[] only defined on array and selection') 365 | } 366 | } else { 367 | // regular name 368 | target = target.getMaybe(name); 369 | } 370 | if (!target) { 371 | // undefined name 372 | throw new CompileError(token, 'Undefined name') 373 | } 374 | this.evalIfNeeded(target); 375 | if (!target.value && this.analyzing) { 376 | // cyclic dependency 377 | let lastToken = arrayLast(this.tokens); 378 | if (lastToken.type === 'call') { 379 | throw new CompileError(lastToken, 'Recursive call outside secondary try clause') 380 | } 381 | throw new CompileError(lastToken, 'Circular reference') 382 | } 383 | 384 | // check conditional access 385 | let conditional = !!target.conditional; 386 | if (target.container instanceof Code && !target.inputLike) { 387 | // FIXME: only allow backward references within code 388 | // FIXME: outside references to outputs are conditionalized on block 389 | assert(target.container.containingItem.contains(from)); 390 | conditional = false; 391 | } 392 | if (conditional && token.text === '~') { 393 | // conditional export is guarded by base 394 | assert(this.conditional); 395 | guard = '!'; 396 | } 397 | if (!!guard !== conditional) { 398 | throw new CompileError( 399 | token, 400 | guard 401 | ? `invalid reference suffix ${guard}` 402 | : 'conditional reference lacks suffix ? or !' 403 | ); 404 | } 405 | if (conditional && guard === '?') { 406 | // reference only conditional with ? suffix. ! crashes 407 | this.conditional = true; 408 | } 409 | } 410 | 411 | // append to path 412 | ids.push(target.id); 413 | guards.push(guard); 414 | } 415 | 416 | target.resolve(); 417 | if (target.evaluated === undefined && !(this instanceof Selection)) { 418 | // cyclic dependency 419 | // Allowed for Selections, which only copy the target lazily 420 | throw new CompileError(arrayLast(this.tokens), 'Circular reference') 421 | } 422 | 423 | // establish Path 424 | this.path = new Path(ids); 425 | this.guards = guards; 426 | 427 | /** FIXME: path context is not necessarily the LUB of base and target of 428 | * ref. This could happen because the path climbed back down the upward path 429 | * to the lexical binding. It can also happen when binding to inclusions. 430 | * This may matter for allowing access to conditional fields. 431 | * 432 | */ 433 | // LUB of structural path must be same as lexical scope 434 | // if (!this.dependent) { 435 | // let lub = this.path.lub(from.path); 436 | // if (lub.length !== this.context) { 437 | // throw new StaticError(this.tokens[0], 'Structural path scope too high') 438 | // } 439 | // TODO: allow higher lexical binding, perhaps to avoid shadowing 440 | // this.context = lub.length; 441 | // // no guards used on context 442 | // // TODO report invalid guards in terms of tokens 443 | // assert( 444 | // this.guards.slice(0, this.context).every( 445 | // guard => guard === undefined 446 | // ) 447 | // ) 448 | // } 449 | } 450 | 451 | /** reference previous value, disallow if a data block conditional. This is OK 452 | * in code block because will already have rejected 453 | * 454 | * TODO: maybe allow 'that?' and 'that!' to explicitly guard previous 455 | * reference in data block. 456 | */ 457 | 458 | private previous(from: Item, token: Token): Item { 459 | let item = from.previous(); 460 | if (!item) { 461 | throw new CompileError(token, 'No previous value'); 462 | } 463 | if ( 464 | item.conditional 465 | && !(item.container instanceof Code) 466 | && !(item.container instanceof Choice) 467 | ) { 468 | // in data block, prev value can't be conditional 469 | throw new CompileError( 470 | token, 471 | 'Previous value is conditional: use explicit guarded reference' 472 | ) 473 | } 474 | return item; 475 | } 476 | 477 | uneval() { 478 | this.rejected = false; 479 | this.target = undefined 480 | } 481 | 482 | /** make copy, bottom up, translating paths contextually */ 483 | copy(srcPath: Path, dstPath: Path): this { 484 | // path must already have been bound 485 | assert(this.path); 486 | let to = another(this); 487 | to.tokens = this.tokens; 488 | to.path = this.path.translate(srcPath, dstPath); 489 | // translate guards 490 | if (srcPath.contains(this.path)) { 491 | // Adjust unguarded accesses to context 492 | assert( 493 | this.guards.slice(0, srcPath.length) 494 | .every(guard => guard === undefined) 495 | ); 496 | 497 | to.guards = [ 498 | ...dstPath.ids.map(() => undefined), 499 | ...this.guards.slice(srcPath.length) 500 | ]; 501 | 502 | to.context = this.context + dstPath.ids.length - srcPath.length; 503 | } else { 504 | to.guards = this.guards; 505 | to.context = this.context; 506 | } 507 | return to; 508 | } 509 | 510 | /** References are the same type if they reference the same location 511 | * contextually */ 512 | updatableFrom(from: Value, fromPath?: Path, thisPath?: Path): boolean { 513 | if (!fromPath) fromPath = from.containingPath; 514 | if (!thisPath) thisPath = this.containingPath; 515 | return ( 516 | from instanceof Reference 517 | // FIXME: compare translated guards 518 | && arrayEquals(this.guards, from.guards) 519 | && this.path.equals(from.path.translate(fromPath, thisPath)) 520 | ) 521 | } 522 | 523 | // equality assumes changeableFrom is true, which requires equality 524 | equals(other: any) { 525 | return true; 526 | } 527 | 528 | // dump path as dotted string 529 | // Leave off versionID if in same version 530 | // FIXME: add guards 531 | dump() { 532 | let ids = this.path.ids; 533 | if (ids[0] === this.containingPath.ids[0]) { 534 | ids = ids.slice(1); 535 | } 536 | return ids.join('.'); 537 | // dump source path 538 | // if (this.tokens[0].text === '.') { 539 | // return '.' + this.tokens.slice(1).join('.'); 540 | // } 541 | // return this.tokens.join('.'); 542 | } 543 | } 544 | 545 | /** Dependent reference to a choice option on target of # */ 546 | /* 547 | Inherited from prior design of choose. Could instead be encoded as a path 548 | leading straight to the option. But then need to avoid rejecting if option not 549 | chosen. 550 | */ 551 | export class OptionReference extends Reference { 552 | 553 | /** name token for option */ 554 | optionToken!: Token; 555 | 556 | /** FieldID of option */ 557 | optionID!: FieldID; 558 | 559 | eval() { 560 | super.eval(); 561 | 562 | if (!this.optionID) { 563 | // bind optionID 564 | let choice = this.target!; 565 | if (!(choice.value instanceof Choice)) { 566 | throw new CompileError(this.optionToken, 'expecting choice'); 567 | } 568 | let option = choice.getMaybe(this.optionToken.text); 569 | if (!option) { 570 | throw new CompileError(this.optionToken, 'no such option'); 571 | } 572 | this.optionID = cast(option.id, FieldID); 573 | } 574 | } 575 | 576 | copy(srcPath: Path, dstPath: Path): this { 577 | let to = super.copy(srcPath, dstPath); 578 | to.optionToken = this.optionToken; 579 | to.optionID = this.optionID; 580 | return to; 581 | } 582 | 583 | updatableFrom(from: Value, fromPath?: Path, thisPath?: Path): boolean { 584 | return ( 585 | from instanceof OptionReference 586 | && this.optionID === from.optionID 587 | && super.updatableFrom(from, fromPath, thisPath) 588 | ); 589 | } 590 | 591 | } -------------------------------------------------------------------------------- /src/tokenize.ts: -------------------------------------------------------------------------------- 1 | import { arrayLast } from "./exports"; 2 | /** @module scanner 3 | * Lexical scanner. No regular expressions were harmed in this module. 4 | */ 5 | 6 | export type TokenType = ( 7 | '::' | ':' | '=' | ':=' | '#' | '.' | ',' | ';' | '{' | '}' | '(' | ')' 8 | | '=|>' | 'on-update' | 'write' | '->' | '<-' | 'updatable' 9 | | '[' | ']' | '[]' | 'string' | 'number' | '###' 10 | | 'name' | 'end' | '\n' 11 | | 'call' | 'arg1' | 'arg2' | 'input' 12 | // keywords - add to matchToken switch statement 13 | | 'record' | 'choice' | 'table' | 'array' | 'do' | 'builtin' | 'anything' 14 | | 'nil' | 'try' | 'check' | 'not' | 'else' | 'reject' | 'let' | 'export' 15 | | 'that' | 'include' | 'with' | 'find?' | 'find!' | 'tracked' 16 | | 'for-all' | 'such-that' | 'all?' | 'all!' | 'none?' | 'none!' | 'accumulate' 17 | | 'extend' | 'selection' | 'any' | 'selecting' | 'link' | 'via' | 'register' 18 | | '::replace' | '::insert' | '::append' | '::convert' | '::delete' 19 | | '::move' | '::move-insert' | '::move-append' 20 | | '::wrap-record' | '::wrap-array' | '::unwrap' | '::nochange' 21 | ) 22 | 23 | export class Token { 24 | constructor( 25 | readonly type: TokenType, 26 | readonly start: number, 27 | readonly end: number, 28 | readonly source: string) 29 | { } 30 | 31 | /** Copy an existing token giving a different type */ 32 | static fake(type: TokenType, token: Token): Token { 33 | return new Token(type, token.start, token.end, token.source); 34 | } 35 | 36 | /** start of a character literal, including the single quote */ 37 | static readonly characterLiteralStart = "character'"; 38 | 39 | 40 | get text() { return this.toString() }; 41 | 42 | toString() { 43 | return this.source.substring(this.start, this.end); 44 | } 45 | 46 | /** Hack to special case parsing of operator names */ 47 | get isOperator(): boolean { 48 | if (this.type !== 'name') return false; 49 | if (this.text.startsWith('not=')) return true; 50 | switch (this.text[0]) { 51 | case '+': 52 | case '-': 53 | case '*': 54 | case '/': 55 | case '=': 56 | case '<': 57 | case '>': 58 | case '&': 59 | case '&&': 60 | return true; 61 | 62 | default: 63 | return false; 64 | } 65 | } 66 | } 67 | 68 | 69 | /** 70 | * Tokenize source string into array of tokens. 71 | * @throws SyntaxError 72 | */ 73 | export function tokenize(source: string): Token[] { 74 | /** Scanning cursor index in source */ 75 | let cursor = 0; 76 | 77 | let tokens: Token[] = []; 78 | 79 | while (cursor < source.length) { 80 | 81 | // comments skip to end of line or source 82 | // TODO: tokenize comments 83 | // if (match('//')) { 84 | // for (let char = source.charCodeAt(cursor); 85 | // cursor < source.length && !newline(char); 86 | // cursor++) {} 87 | // continue; 88 | // } 89 | 90 | let char = source.charCodeAt(cursor); 91 | 92 | // tokenize newlines 93 | if (newline(char)) { 94 | // ignore if first token or repeated 95 | if (tokens.length && arrayLast(tokens).type !== '\n') { 96 | tokens.push(new Token('\n', cursor - 1, cursor, source)); 97 | } 98 | cursor++; 99 | continue; 100 | } 101 | 102 | // skip whitespace 103 | if (char <= 0x20) { 104 | cursor++; 105 | continue; 106 | } 107 | 108 | let start = cursor; 109 | let type = matchToken(); 110 | tokens.push(new Token(type, start, cursor, source)); 111 | continue; 112 | } 113 | 114 | if (!tokens.length) { 115 | throw new SyntaxError(source.length, source, 'Expecting something') 116 | } 117 | 118 | return tokens; 119 | 120 | /** 121 | * Matches token in `source` at `cursor`. 122 | * Returns a TokenType with `cursor` advanced 123 | * @throws SyntaxError 124 | */ 125 | function matchToken(): TokenType { 126 | let start = cursor; 127 | 128 | // match single-quoted string and character literal 129 | if (match("'") || match(Token.characterLiteralStart)) { 130 | while (true) { 131 | // string must be terminated on same line 132 | if (atNewline()) { 133 | throw syntaxError("expecting '"); 134 | } 135 | if (match("'")) return 'string'; 136 | if (match('\\')) { 137 | // backslash escape 138 | // can't escape newline 139 | if (atNewline()) { 140 | throw syntaxError('expecting something after \\') 141 | } 142 | if (match('x')) { 143 | if (!matchHexDigit() || !matchHexDigit()) { 144 | throw syntaxError('expecting 2 hex digits after \\x') 145 | } 146 | continue; 147 | } 148 | if (match('u')) { 149 | if ( 150 | !matchHexDigit() 151 | || !matchHexDigit() 152 | || !matchHexDigit() 153 | || !matchHexDigit()) { 154 | throw syntaxError('expecting 4 hex digits after \\u') 155 | } 156 | continue; 157 | } 158 | } 159 | cursor++; 160 | continue; 161 | } 162 | } 163 | 164 | // operators are names 165 | if (match('+')) return 'name'; 166 | if (match('*')) return 'name'; 167 | if (match('/')) return 'name'; 168 | if (match('&&')) return 'name'; 169 | if (match('&')) return 'name'; 170 | if (match('=?')) return 'name'; 171 | if (match('>?')) return 'name'; 172 | if (match('=?')) return 'name'; 174 | if (match('<=?')) return 'name'; 175 | if (match('not=?')) return 'name'; 176 | if (match('=!')) return 'name'; 177 | if (match('>!')) return 'name'; 178 | if (match('=!')) return 'name'; 180 | if (match('<=!')) return 'name'; 181 | if (match('not=!')) return 'name'; 182 | 183 | // special values 184 | if (match('###')) return '###'; 185 | 186 | // edit operations 187 | if (match('::nochange')) return '::nochange'; 188 | if (match('::replace')) return '::replace'; 189 | if (match('::insert')) return '::insert'; 190 | if (match('::append')) return '::append'; 191 | if (match('::convert')) return '::convert'; 192 | if (match('::delete')) return '::delete'; 193 | if (match('::move-insert')) return '::move-insert'; 194 | if (match('::move-append')) return '::move-append'; 195 | if (match('::move')) return '::move'; 196 | if (match('::wrap-record')) return '::wrap-record'; 197 | if (match('::wrap-array')) return '::wrap-array'; 198 | if (match('::unwrap')) return '::unwrap'; 199 | 200 | // punctuation 201 | if (match('.')) return '.'; 202 | if (match(',')) return ','; 203 | if (match(';')) return ';'; 204 | if (match(':=')) return ':='; 205 | if (match('#')) return '#'; 206 | if (match('::')) return '::'; 207 | if (match(':')) return ':'; 208 | if (match('=|>')) return '=|>'; 209 | if (match('=')) return '='; 210 | if (match('->')) return '->'; 211 | if (match('<-')) return '<-'; 212 | if (match('{')) return '{'; 213 | if (match('}')) return '}'; 214 | if (match('(')) return '('; 215 | if (match(')')) return ')'; 216 | if (match('[]')) return '[]'; 217 | if (match('[')) return '['; 218 | if (match(']')) return ']'; 219 | 220 | // match minus 221 | let minus = match('-'); 222 | // match number 223 | if (matchNumber()) return 'number' 224 | // convert lone minus into name of subtraction operation 225 | if (minus) return 'name'; 226 | 227 | // match name, optionally prefixed by metadata or importcharacter 228 | if (matchAlpha() || match('^') || match('~')) { 229 | while (true) { 230 | // allow internal hyphen, underscore, and ampersand 231 | if (match('-') || match('_') || match('&')) { 232 | while (match('-') || match('_') || match('&')); 233 | if (matchAlpha() || matchDigit()) continue; 234 | throw syntaxError('expecting alphanumeric character'); 235 | } 236 | // allow trailing ? 237 | if (match('?')) { 238 | if (matchAlpha() || matchDigit() || match('-') || match('_')) { 239 | throw syntaxError('? can only be at end of name'); 240 | } 241 | break; 242 | } 243 | // allow trailing ! 244 | if (match('!')) { 245 | if (matchAlpha() || matchDigit() || match('-') || match('_')) { 246 | throw syntaxError('! can only be at end of name'); 247 | } 248 | break; 249 | } 250 | if (!(matchAlpha() || matchDigit())) break; 251 | continue; 252 | } 253 | let name = source.substring(start, cursor); 254 | 255 | // override name with keywords, which equal their TokenType 256 | switch (name) { 257 | case 'record': 258 | case 'do': 259 | case 'with': 260 | case 'choice': 261 | case 'try': 262 | case 'check': 263 | case 'let': 264 | case 'export': 265 | case 'not': 266 | case 'else': 267 | case 'reject': 268 | case 'table': 269 | case 'array': 270 | case 'tracked': 271 | case 'anything': 272 | case 'nil': 273 | case 'builtin': 274 | case 'that': 275 | case 'include': 276 | case 'find?': 277 | case 'find!': 278 | case 'for-all': 279 | case 'such-that': 280 | case 'selecting': 281 | case 'all?': 282 | case 'all!': 283 | case 'none?': 284 | case 'none!': 285 | case 'accumulate': 286 | case 'on-update': 287 | case 'updatable': 288 | case 'write': 289 | case 'extend': 290 | case 'selection': 291 | case 'any': 292 | case 'link': 293 | case 'via': 294 | case 'register': 295 | 296 | return name; 297 | } 298 | // name not a keyword 299 | return 'name'; 300 | } 301 | 302 | 303 | throw syntaxError('not recognized') 304 | } 305 | 306 | /** 307 | * Matches unsigned number, advancing cursor 308 | * @throws SyntaxError 309 | */ 310 | function matchNumber(): boolean { 311 | if (!matchDigit()) return false; 312 | while (matchDigit()); 313 | // fraction 314 | if (match('.')) { 315 | if (!matchDigit()) { 316 | throw syntaxError('expecting fractional number'); 317 | } 318 | while (matchDigit()); 319 | } 320 | // exponent 321 | if (match('e') || match('E')) { 322 | // optional exponent sign 323 | match('+') || match('-'); 324 | if (!matchDigit()) { 325 | throw syntaxError('expecting exponent'); 326 | } 327 | while (matchDigit()); 328 | } 329 | return true; 330 | } 331 | 332 | 333 | /** 334 | * Matches alphabetic character, advancing cursor 335 | */ 336 | function matchAlpha(): boolean { 337 | let char = source.charCodeAt(cursor); 338 | if ( 339 | (char >= 0x41 && char <= 0x5a) 340 | || (char >= 0x61 && char <= 0x7a)) { 341 | cursor++; 342 | return true; 343 | } 344 | return false; 345 | } 346 | 347 | /** 348 | * Matches numeric digit, advancing cursor 349 | */ 350 | function matchDigit(): boolean { 351 | let char = source.charCodeAt(cursor); 352 | if (char >= 0x30 && char <= 0x39) { 353 | cursor++; 354 | return true; 355 | } 356 | return false; 357 | } 358 | 359 | /** 360 | * Matches hex digit, advancing cursor 361 | */ 362 | function matchHexDigit(): boolean { 363 | let char = source.charCodeAt(cursor); 364 | if ( 365 | (char >= 0x30 && char <= 0x39) 366 | || (char >= 0x41 && char <= 0x46) 367 | || (char >= 0x61 && char <= 0x66)) { 368 | cursor++; 369 | return true; 370 | } 371 | return false; 372 | } 373 | 374 | /** 375 | * Match a string in `source` at `cursor`. 376 | * On success advances `cursor` and returns true. 377 | */ 378 | function match(s: string): boolean { 379 | for (let i = 0; i < s.length; i++) { 380 | if (s[i] !== source[cursor + i]) { 381 | return false; 382 | } 383 | } 384 | cursor += s.length; 385 | return true; 386 | } 387 | 388 | function syntaxError(message: string): SyntaxError { 389 | return new SyntaxError(cursor, source, message); 390 | } 391 | 392 | function atNewline() { 393 | return cursor >= source.length || newline(source.charCodeAt(cursor)); 394 | } 395 | } 396 | 397 | function newline(char: number) { 398 | return char >= 0xa && char <= 0xd; 399 | } 400 | 401 | /** whether string is an operator */ 402 | export function isOperator(s: string) { 403 | switch (s[0]) { 404 | case '+': 405 | case '-': 406 | case '*': 407 | case '/': 408 | case '=': 409 | case '<': 410 | case '>': 411 | return true; 412 | } 413 | return s.startsWith('not='); 414 | } 415 | 416 | export class SyntaxError extends Error { 417 | constructor(readonly cursor: number, readonly source: string, 418 | readonly description = "unrecognized syntax") { 419 | super( 420 | description + ' [' + cursor + ']' + source.slice(cursor, cursor + 10) 421 | ); 422 | this.name = 'SyntaxError'; 423 | } 424 | } 425 | -------------------------------------------------------------------------------- /src/try.ts: -------------------------------------------------------------------------------- 1 | import { Do, CompileError, arrayLast, Crash, Field, Code, assert, cast, Choice, assertDefined, trap, Item, Reference, Statement, arrayRemove } from "./exports"; 2 | 3 | /** a try block is the basic control structure of Subtext. It contains a sequnce 4 | * of do-blocks called clauses. The clauses are executed in order until one does 5 | * not reject */ 6 | export class Try extends Code { 7 | 8 | /** evaluate, setting this.result to first successful clause, else 9 | * this.rejected. this.conditional true if fall-through rejects instead of crashing 10 | * */ 11 | eval() { 12 | if (this.result || this.rejected) { 13 | // aleady evaluated 14 | return; 15 | } 16 | 17 | if (this.analyzing) { 18 | 19 | // during analysis first clause becomes result, other clauses queued for 20 | // later analysis to allow recursion 21 | 22 | /** FIXME - this has caused so much pain! Needed to allow implicitly 23 | recursive functions. Instead have an explicit `recursive-type exp` 24 | statement following the arguments. Maybe too late for this 25 | implementation. Note recursive choices will probably still need 26 | deferred analysis and copying */ 27 | 28 | let first = this.fields[0]; 29 | for (let clause of this.fields) { 30 | /** function to analyze clause */ 31 | const analyzeClause = () => { 32 | clause.eval(); 33 | if ( 34 | !clause.conditional && ( 35 | this.conditional 36 | || clause !== arrayLast(this.fields) 37 | ) 38 | ) { 39 | throw new CompileError(clause, 40 | 'clause must be conditional if not last' 41 | ) 42 | } 43 | first.eval(); 44 | if (clause === first) { 45 | // set first clause as result during analysis 46 | this.result = first; 47 | // uncopy first clause result to break value provenance 48 | (cast(first.get('^code').value, Code).result!).uncopy(); 49 | } else { 50 | // not first clause 51 | if (!first.value!.updatableFrom(clause.value!)) { 52 | // type incompatible with first clause 53 | //result') 54 | // assert type error on the clause itself 55 | let code = clause.get('^code'); 56 | // note overriding eval errors in clause 57 | code.editError = 'type'; 58 | assert(!code.isDerived); // to be sure error sticks 59 | clause.propagateError(code); 60 | } 61 | if (this.getMaybe('^export') && !clause.getMaybe('^export')) { 62 | throw new CompileError(clause, 'all clauses must export') 63 | } 64 | } 65 | } 66 | if (clause === first) { 67 | // immediately analyze first clause, setting result type 68 | analyzeClause(); 69 | } else if (!clause.deferral) { 70 | // defer secondary clauses so they can make recursive calls 71 | // but ignore if a deferred copy of a deferred clause 72 | assert(clause.evaluated === false); 73 | clause.evaluated = undefined; 74 | clause.deferral = analyzeClause; 75 | this.workspace.analysisQueue.push(clause); 76 | } 77 | continue; 78 | } 79 | 80 | // if first clause exports, export a choice combining all exports 81 | let exportField = first.getMaybe('^export'); 82 | if (exportField) { 83 | // export choice from clause exports 84 | let choice = new Choice; 85 | this.export = this.containingItem.setMeta('^export', choice); 86 | this.export.conditional = this.conditional; 87 | this.fields.forEach(clause => { 88 | let option = new Field; 89 | // option adopts name of clause 90 | if (clause.id.name === undefined) { 91 | throw new CompileError(clause, 'exporting clause must be named') 92 | } 93 | option.id = clause.id; 94 | choice.add(option); 95 | option.io = 'input'; 96 | option.conditional = true; 97 | // defer defining option value till clauses analyzed 98 | this.export!.workspace.analysisQueue.push(option); 99 | option.deferral = () => { 100 | clause.resolve(); 101 | let clauseExport = cast(clause.get('^code').value, Code).export; 102 | if (!clauseExport) { 103 | throw new CompileError(clause, 'all clauses must export') 104 | } 105 | // export to option value 106 | this.fieldImport(option, clauseExport); 107 | option.eval(); 108 | } 109 | }) 110 | } 111 | 112 | return; 113 | } 114 | 115 | // at runtime evaluate clauses until success 116 | for (let clause of this.fields) { 117 | clause.eval(); 118 | if (!clause.rejected) { 119 | // stop on success when not analyzing 120 | this.result = clause; 121 | this.propagateError(clause); 122 | if (clause.get('^code').editError === 'type') { 123 | // Set result to first clause to get correct type 124 | this.result = this.fields[0]; 125 | } 126 | // set export choice 127 | this.export = this.containingItem.getMaybe('^export'); 128 | if (this.export) { 129 | let choice = cast(this.export.value, Choice); 130 | let option = choice.setChoice(this.fields.indexOf(clause)) 131 | option.detachValueIf(); 132 | option.copyValue(assertDefined(clause.getMaybe('^export'))); 133 | } 134 | break; 135 | } 136 | } 137 | 138 | // reject or crash on fall-through 139 | if (!this.result) { 140 | if (this.conditional) { 141 | this.rejected = true; 142 | let exportField = this.getMaybe('^export'); 143 | if (exportField) { 144 | exportField.rejected = true; 145 | } 146 | } else { 147 | throw new Crash(arrayLast(this.fields).id.token!, 'try failed') 148 | } 149 | } 150 | } 151 | 152 | /** nested write statements. During analysis merges writes of clauses */ 153 | writeStatements(): Statement[] { 154 | if (!this.analyzing) { 155 | // when not analyzing use only the executed clause 156 | for (let clause of this.statements) { 157 | if (!clause.rejected) { 158 | return clause.writeStatements(); 159 | } 160 | } 161 | return []; 162 | } 163 | // during analysis merge write statements from each clause 164 | let mergedWrites: Statement[] = []; 165 | for (let clause of this.statements) { 166 | let prevMerged = mergedWrites.slice(); 167 | writeLoop: for (let write of clause.writeStatements()) { 168 | let writeTarget = cast(write.get('^target').value, Reference).target!; 169 | // scan writes merged from previous clauses 170 | for (let merged of prevMerged) { 171 | let mergedTarget = 172 | cast(merged.get('^target').value, Reference).target!; 173 | if (writeTarget === mergedTarget) { 174 | // drop the write 175 | continue writeLoop; 176 | } 177 | if ( 178 | writeTarget.contains(mergedTarget) 179 | && writeTarget.writeSink(mergedTarget)!.io !== 'interface' 180 | ) { 181 | // drop contained merged write unless within interface 182 | arrayRemove(mergedWrites, merged); 183 | } else if ( 184 | mergedTarget.contains(writeTarget) 185 | && mergedTarget.writeSink(writeTarget)!.io !== 'interface' 186 | ) { 187 | // drop contained write unless within interface 188 | continue writeLoop; 189 | } 190 | } 191 | mergedWrites.push(write); 192 | continue writeLoop; 193 | } 194 | } 195 | return mergedWrites; 196 | } 197 | 198 | } -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module 3 | * 4 | * Utility functions 5 | */ 6 | 7 | 8 | /** Type of dynamic object */ 9 | export interface Dictionary { 10 | [key: string]: T; 11 | } 12 | 13 | /** type guard argument as an object, not a primitive value */ 14 | export function isObject(x: any): x is object { 15 | return x !== null && typeof x === 'object'; 16 | } 17 | 18 | export function isString(x: any): x is string { 19 | return typeof x === 'string'; 20 | } 21 | 22 | export function isNumber(x: any): x is number { 23 | return typeof x === 'number'; 24 | } 25 | 26 | /** Returns a new instance of an object. Calls constructor with no args */ 27 | export function another(existing: T): T { 28 | return new (existing.constructor as new () => T); 29 | } 30 | 31 | /** 32 | * Safe class cast, thanks to Ryan Cavanaugh and Duan Yo 33 | * Throws exception on failure. 34 | */ 35 | export function cast(instance: any, ctor: { new(...args: any[]): T }): T { 36 | if (instance instanceof ctor) return instance; 37 | trap('class cast exception'); 38 | } 39 | 40 | 41 | /** trap if value undefined, returns value otherwise */ 42 | export function assertDefined(x: T): Exclude { 43 | assert(x !== undefined); 44 | return x as Exclude; 45 | } 46 | 47 | 48 | /** Assertion checking */ 49 | export function assert(condition: any, message = 'failed assertion'): 50 | asserts condition { 51 | if (!condition) { 52 | trap(message); 53 | } 54 | } 55 | 56 | /** trap */ 57 | export function trap(message = 'internal error'): never { 58 | debugger; 59 | throw new Trap(message); 60 | } 61 | 62 | /** Trap exception */ 63 | class Trap extends Error { 64 | constructor(message = 'internal error') { 65 | super(message); 66 | this.name = 'Trap'; 67 | } 68 | } 69 | 70 | 71 | // Array utils 72 | 73 | /** return last element of array else trap */ 74 | export function arrayLast(array: ReadonlyArray): T { 75 | if (array.length === 0) trap(); 76 | return array[array.length - 1]; 77 | } 78 | 79 | // /** allow negative indices from end. -1 is last element */ 80 | // export function arrayBack(array: T[], index: number): T { 81 | // if (index >= 0 || array.length < - index) trap(); 82 | // return array[array.length + index]; 83 | // } 84 | 85 | /** remove first occurence of a value in an array */ 86 | export function arrayRemove(array: T[], value: T) { 87 | let i = array.indexOf(value); 88 | assert(i >= 0); 89 | array.splice(i, 1); 90 | } 91 | 92 | /** update first occurence of a value in an array */ 93 | export function arrayReplace(array: T[], value: T, replacement: T) { 94 | let i = array.indexOf(value); 95 | assert(i >= 0); 96 | array.splice(i, 1, replacement); 97 | } 98 | 99 | /** returns a reversed copy of an array */ 100 | export function arrayReverse(array: T[]): T[] { 101 | return array.slice().reverse(); 102 | } 103 | 104 | export function arrayEquals(xs: T[], ys: T[]) { 105 | return (xs.length === ys.length && xs.every((x, i) => x === ys[i])); 106 | } 107 | 108 | // String utils 109 | 110 | /** convert args into strings and concatenate them, skipping falsey values. 111 | * Useful for args of the form `test && string` */ 112 | export function concatIf(...args: any[]): string { 113 | let result = ''; 114 | args.forEach(arg => { 115 | if (arg) result += arg; 116 | }); 117 | return result; 118 | } 119 | 120 | /* 121 | String escaping code copied from 122 | https://github.com/harc/ohm/blob/master/src/common.js 123 | */ 124 | 125 | /** convert string escape sequences */ 126 | export function stringUnescape(input: string): string { 127 | let output = ''; 128 | for (let i = 0; i < input.length; i++) { 129 | let c = input[i]; 130 | if (c === '\\') { 131 | c = input[++i]; 132 | switch (c) { 133 | case 'b': 134 | c = '\b'; 135 | break; 136 | case 'f': 137 | c = '\f'; 138 | break; 139 | case 'n': 140 | c = '\n'; 141 | break; 142 | case 'r': 143 | c = '\r'; 144 | break; 145 | case 't': 146 | c = '\t'; 147 | break; 148 | case 'x': 149 | c = String.fromCharCode( 150 | parseInt(input.substring(i + 1, i + 3), 16)); 151 | i += 2; 152 | break; 153 | case 'u': 154 | c = String.fromCharCode( 155 | parseInt(input.substring(i + 1, i + 5), 16)); 156 | i += 4; 157 | break; 158 | } 159 | } 160 | output += c; 161 | } 162 | return output; 163 | } 164 | 165 | /** 166 | * Returns single-quoted escaped string 167 | */ 168 | export function escapedString(s: string): string { 169 | let out = "'"; 170 | for (let c of s) { 171 | out += charEscape(c, "'"); 172 | } 173 | return out + "'"; 174 | } 175 | /** 176 | * Returns escaped representation of character c. 177 | * If delim is specified only that kind of quote will be escaped. 178 | * Copied from ohm/src/common.js 179 | */ 180 | function charEscape(c: string, delim?: string) { 181 | let charCode = c.charCodeAt(0); 182 | if ((c === '"' || c === "'") && delim && c !== delim) { 183 | return c; 184 | } else if (charCode < 128) { 185 | return escapeStringFor[charCode]; 186 | } else if (128 <= charCode && charCode < 256) { 187 | return '\\x' + pad(charCode.toString(16), 2); 188 | } else { 189 | return '\\u' + pad(charCode.toString(16), 4); 190 | } 191 | } 192 | 193 | function pad(numberAsString: string, len: number): string { 194 | let zeros: string[] = []; 195 | for (let idx = 0; idx < numberAsString.length - len; idx++) { 196 | zeros.push('0'); 197 | } 198 | return zeros.join('') + numberAsString; 199 | } 200 | 201 | const escapeStringFor: { [index: number]: string } = {}; 202 | for (let c = 0; c < 128; c++) { 203 | escapeStringFor[c] = String.fromCharCode(c); 204 | } 205 | escapeStringFor["'".charCodeAt(0)] = "\\'"; 206 | escapeStringFor['"'.charCodeAt(0)] = '\\"'; 207 | escapeStringFor['\\'.charCodeAt(0)] = '\\\\'; 208 | escapeStringFor['\b'.charCodeAt(0)] = '\\b'; 209 | escapeStringFor['\f'.charCodeAt(0)] = '\\f'; 210 | escapeStringFor['\n'.charCodeAt(0)] = '\\n'; 211 | escapeStringFor['\r'.charCodeAt(0)] = '\\r'; 212 | escapeStringFor['\t'.charCodeAt(0)] = '\\t'; -------------------------------------------------------------------------------- /src/value.ts: -------------------------------------------------------------------------------- 1 | import { Workspace, Item, trap, ID, Path, another, Token, assert, assertDefined, Version } from "./exports"; 2 | 3 | /** Every Value is contained in an Item */ 4 | export abstract class Value { 5 | 6 | /** Item containing this value */ 7 | containingItem!: Item; 8 | /** Path of containing item */ 9 | get containingPath() { return this.containingItem.path; } 10 | 11 | get version(): Version { return this.containingItem.version} 12 | get workspace(): Workspace { return this.containingItem.workspace } 13 | get analyzing() { return this.workspace.analyzing; } 14 | 15 | get id(): ID { return this.containingItem.id } 16 | 17 | /** logical container (skipping base field of metadata) */ 18 | get up(): Item { 19 | return this.containingItem; 20 | } 21 | 22 | /** the item with an ID */ 23 | get(id: ID): Item { 24 | return assertDefined(this.getMaybe(id)); 25 | } 26 | 27 | /** the item with an ID else undefined */ 28 | getMaybe(id: ID): Item | undefined { 29 | return undefined; 30 | } 31 | 32 | /** source token where defined */ 33 | token?: Token; 34 | 35 | /** true if this is the designated blank value for the type */ 36 | abstract isBlank(): boolean; 37 | 38 | /** evaluate contents */ 39 | abstract eval(): void; 40 | 41 | /** unevaluate */ 42 | abstract uneval(): void; 43 | 44 | /** source of value through copying */ 45 | source?: this; 46 | 47 | /** original source of this value via copying. That is, its definition */ 48 | get origin(): this { 49 | return this.source ? this.source.origin : this; 50 | } 51 | 52 | /** edit error stored in containing Item */ 53 | get editError() { return this.containingItem.editError } 54 | /** propagate error to containing item */ 55 | propagateError(from: Item | Value) { 56 | this.containingItem.propagateError(from); 57 | } 58 | 59 | /** make copy, bottom up, translating paths contextually */ 60 | copy(srcPath: Path, dstPath: Path): this { 61 | let to = another(this); 62 | to.source = this; 63 | to.token = this.token; 64 | return to; 65 | } 66 | 67 | /** type compatibility */ 68 | updatableFrom(from: Value, fromPath?: Path, thisPath?: Path): boolean { 69 | return this.constructor === from.constructor; 70 | } 71 | 72 | /** value equality, assuming type equality */ 73 | equals(other: any): boolean { 74 | trap(); 75 | } 76 | 77 | /** whether this value was transitively copied from another Value without any 78 | * updates. Only used during analysis */ 79 | isCopyOf(ancestor: this): boolean { 80 | assert(this.analyzing); 81 | if (!this.source) return false; 82 | // check if our source is a copy 83 | return this.source === ancestor || this.source.isCopyOf(ancestor) 84 | } 85 | 86 | /** static equality check. During analysis uses ifCopyOf, otherwise equals */ 87 | staticEquals(other: this): boolean { 88 | return this.analyzing ? this.isCopyOf(other) : this.equals(other); 89 | } 90 | 91 | /** whether contains an input field with an Anything value */ 92 | get isGeneric() { return false } 93 | 94 | /** dump into a plain JS value for testing */ 95 | abstract dump(): any; 96 | } 97 | -------------------------------------------------------------------------------- /src/workspace.ts: -------------------------------------------------------------------------------- 1 | import { Head, History, Item, Path, Parser, Version, VersionID, FieldID, Token, Statement, CompileError, Try, Call, Do, With, Reference, assertDefined, Container, _Array, isNumber } from "./exports"; 2 | 3 | /** A subtext workspace */ 4 | export class Workspace extends Item { 5 | 6 | /** Workspace is at the top of the tree */ 7 | declare container: never; 8 | _path = Path.empty; 9 | 10 | /** whether eval() should do analysis */ 11 | private _analyzing: boolean = false; 12 | get analyzing() { return this._analyzing; } 13 | 14 | /** serial numbers assigned to FieldIDs */ 15 | private fieldSerial = 0; 16 | 17 | newFieldID(name?: string, token?: Token): FieldID { 18 | let serial = ++this.fieldSerial 19 | let id = new FieldID(serial); 20 | id.name = name; 21 | id.token = token; 22 | return id; 23 | } 24 | 25 | // FIXME: FieldIDs maybe allocated within versionIDs 26 | private newVersionID(name: string): FieldID { 27 | let serial = ++this.fieldSerial 28 | let id = new VersionID(serial); 29 | id.name = name; 30 | return id; 31 | } 32 | 33 | /** add a new version to history */ 34 | private newVersion(): Version { 35 | let history = this.value!; 36 | let newVersion = new Version; 37 | // using time as label 38 | newVersion.id = this.newVersionID(new Date().toLocaleString()); 39 | newVersion.io = 'data'; 40 | newVersion.formulaType = 'none'; 41 | history.add(newVersion); 42 | return newVersion; 43 | } 44 | 45 | /** current version of workspace */ 46 | get currentVersion() { 47 | return this.value!.currentVersion; 48 | } 49 | 50 | 51 | /** queue of items with deferred analysis */ 52 | analysisQueue: Item[] = []; 53 | /** queue of functions to analyze exports */ 54 | exportAnalysisQueue: (()=>void)[] = []; 55 | 56 | /** compile a doc 57 | * @param source 58 | * @param builtin whether to include builtins first 59 | * @throws SyntaxError 60 | */ 61 | static compile(source: string, builtins = true): Workspace { 62 | if (builtins) { 63 | source = "builtins = include builtins\n" + source; 64 | } 65 | let ws = new Workspace; 66 | let history = new History; 67 | ws.value = history; 68 | history.containingItem = ws; 69 | let version = new Version; 70 | version.id = ws.newVersionID('initial'); 71 | history.add(version); 72 | let head = new Head; 73 | version.value = head; 74 | head.containingItem = version; 75 | // compile 76 | let parser = new Parser(source); 77 | parser.requireHead(head); 78 | 79 | // analyze 80 | ws.analyze(version); 81 | 82 | version.evaluated = true; 83 | return ws; 84 | } 85 | 86 | /** analyze a version. Sets Item.editError for errors that can occur during 87 | * editing. Throws exceptions on errors that only occur in compiling. */ 88 | analyze(version: Version) { 89 | const head = assertDefined(version.value); 90 | 91 | // set global analyzing flag and evaluate to do analysis 92 | // assumes currently unevaluated 93 | this._analyzing = true; 94 | head.eval(); 95 | 96 | // execute deffered analysis 97 | while (this.analysisQueue.length) { 98 | this.analysisQueue.shift()!.resolve(); 99 | } 100 | while (this.exportAnalysisQueue.length) { 101 | this.exportAnalysisQueue.shift()!(); 102 | } 103 | 104 | // analyze updates of visible interfaces 105 | const analyzeUpdates = (container: Container) => { 106 | for (let item of container.items) { 107 | // (seems unneeded now) Skip unevaluated items to avoid recursive choice 108 | // if (!item.value || item.deferral) continue; 109 | if (item.io === 'interface') { 110 | // analyze interface update 111 | let delta = item.setDelta(item); 112 | // uncopy value so treated as different 113 | delta.uncopy(); 114 | version.feedback(version, item); 115 | } else if (item.io === 'input' && item.value instanceof Container) { 116 | // drill into input containers 117 | analyzeUpdates(item.value); 118 | } 119 | } 120 | }; 121 | analyzeUpdates(head); 122 | 123 | // unevaluate to discard analysis computations 124 | head.uneval(); 125 | 126 | // Item.resolve() calls might have triggered evaluation. 127 | // Signature is throwing 'unused value: index: 0' 128 | // this was happening on a try inside a on-update, but now those are being 129 | // forced to resolve 130 | 131 | // clear analyzing flag 132 | this._analyzing = false; 133 | 134 | // check for unused code statements and validate do/with blocks 135 | for (let item of version.visit()) { 136 | // ignore array entries (possible after edits) 137 | // FIXME: not sure why array entries trigger unusued value error 138 | if (item.path.ids.slice(version.path.length).find( 139 | id => isNumber(id) && id !== 0) 140 | ) { 141 | continue; 142 | } 143 | 144 | if (item instanceof Statement && 145 | !item.used 146 | && (item.dataflow === undefined || item.dataflow === 'let') 147 | && !(item.container instanceof Try) 148 | && !(item.container instanceof Call) 149 | ) { 150 | throw new CompileError(item, 'unused value'); 151 | } 152 | if (item.value instanceof Do && item.usesPrevious) { 153 | throw new CompileError(item, 'do-block cannot use previous value'); 154 | } 155 | if (item.value instanceof With && !item.usesPrevious) { 156 | throw new CompileError(item, 'with-block must use previous value'); 157 | } 158 | } 159 | 160 | // evaluate version 161 | head.eval(); 162 | } 163 | 164 | /** dump item at string path in current version */ 165 | dumpAt(path: string): Item { 166 | return this.currentVersion.down(path).dump(); 167 | } 168 | 169 | 170 | 171 | 172 | /** create a new version by updating the current one. 173 | * 174 | * Executes `path := formula`. 175 | * @param path dotted string allowing array indices. 176 | * @param formula syntax of formula. 177 | * */ 178 | updateAt(path: string, formula: string) { 179 | // target is dependent reference to target in previous version 180 | let target = this.currentVersion.down(path); 181 | if (!this.currentVersion.writeSink(target)) { 182 | throw 'not updatable'; 183 | } 184 | let targetRef = new Reference; 185 | targetRef.path = target.path; 186 | // flag as dependent ref 187 | targetRef.tokens = [new Token('that', 0, 0, '')]; 188 | // context of the reference is the previous version 189 | targetRef.context = 1; 190 | // Assert all conditionals along path 191 | targetRef.guards = []; 192 | for ( 193 | let up = target; 194 | !!up.container; 195 | up = up.container.containingItem 196 | ) { 197 | targetRef.guards.unshift(up.conditional ? '!' : undefined); 198 | } 199 | 200 | // append new version to history 201 | let newVersion = this.newVersion(); 202 | // new version formula is a update or choose command 203 | newVersion.formulaType = 'update'; 204 | newVersion.setMeta('^target', targetRef); 205 | 206 | // compile payload from formula 207 | let payload = newVersion.setMeta('^payload') 208 | let parser = new Parser(formula); 209 | parser.space = this; 210 | parser.requireFormula(payload); 211 | 212 | // evaluate new version 213 | newVersion.eval(); 214 | 215 | // Throw edit error 216 | if (newVersion.editError) { 217 | throw 'edit error: ' + newVersion.originalEditError; 218 | } 219 | } 220 | 221 | /** write a JS value to a path */ 222 | writeAt(path: string, value: number | string) { 223 | let formula: string; 224 | if (typeof value === 'string') { 225 | formula = "'" + value + "'"; 226 | } else { 227 | formula = value.toString(); 228 | } 229 | this.updateAt(path, formula); 230 | } 231 | 232 | /** set a path to `on` */ 233 | turnOn(path: string) { 234 | this.updateAt(path, 'on'); 235 | } 236 | 237 | /** create an item in an array */ 238 | createAt(path: string) { 239 | this.updateAt(path, '&()'); 240 | } 241 | 242 | /** delete an item from an array */ 243 | deleteAt(path: string, index: number) { 244 | this.updateAt(path, 'delete! ' + index); 245 | } 246 | 247 | /** add a selection */ 248 | selectAt(path: string, index: number) { 249 | this.updateAt(path, 'select! ' + index); 250 | } 251 | 252 | /** remove a selection */ 253 | deselectAt(path: string, index: number) { 254 | this.updateAt(path, 'deselect! ' + index); 255 | } 256 | 257 | 258 | /** Execute an edit command. 259 | * @param path string path without leading . 260 | * @param command edit command starting with :: 261 | */ 262 | editAt(path: string, command: string) { 263 | // target is dependent reference to target in previous version 264 | let target = this.currentVersion.down(path); 265 | let targetRef = new Reference; 266 | targetRef.path = target.path; 267 | // flag as dependent ref 268 | targetRef.tokens = [new Token('that', 0, 0, '')]; 269 | // context of the reference is the previous version 270 | targetRef.context = 1; 271 | // ignore conditionals 272 | targetRef.guards = []; 273 | for ( 274 | let up = target; 275 | !!up.container; 276 | up = up.container.containingItem 277 | ) { 278 | targetRef.guards.unshift(undefined); 279 | } 280 | 281 | // append new version to history 282 | let newVersion = this.newVersion(); 283 | // new version formula is a update or choose command 284 | newVersion.formulaType = 'update'; 285 | newVersion.setMeta('^target', targetRef); 286 | 287 | // compile command 288 | let parser = new Parser(command); 289 | parser.space = this; 290 | parser.requireEdit(newVersion); 291 | 292 | // evaluate new version 293 | newVersion.eval(); 294 | } 295 | 296 | /** array of items in current version with edit errors */ 297 | get editErrors(): Item[] { 298 | return this.currentVersion.editErrors; 299 | } 300 | 301 | /** Array of edit error messages in current version */ 302 | get editErrorMessages(): string[] { 303 | return this.currentVersion.editErrorMessages; 304 | } 305 | } -------------------------------------------------------------------------------- /test/array.test.ts: -------------------------------------------------------------------------------- 1 | import { expectCompiling, expectDump, compile, expectErrors } from "./exports" 2 | 3 | test('array definition', () => { 4 | expectDump("a = array{0}, b? = a[] =? 0") 5 | .toEqual({ a: [], b: 0 }); 6 | expectDump("a = array{###}, b = array{###}, c? = a =? b") 7 | .toEqual({ a: [], b: [], c: [] }); 8 | expectErrors("a = array{###}, b = array{''}, c? = a =? b") 9 | .toContain('c: type'); 10 | }); 11 | 12 | test('text', () => { 13 | expectDump("a = ''; b = array{character' '}; c? = a =? b") 14 | .toEqual({ a: '', b: '', c: '' }); 15 | expectDump("a = 'a'; b = array{character' '} & character'a'; c? = a =? b") 16 | .toEqual({ a: 'a', b: 'a', c: 'a' }); 17 | expectDump("a = ' \\nfoo' skip-white() =! 'foo'") 18 | .toEqual({a: 'foo'}) 19 | }) 20 | 21 | test('array add/delete', () => { 22 | expectDump("a = array{###}, b = a & 1 & 2, c = b~index, d = b length()") 23 | .toEqual({ a: [], b: [1, 2], c: 2, d: 2 }); 24 | expectDump("a = array{###} & 1, b = array{###} & 1, c? = a =? b") 25 | .toEqual({ a: [1], b: [1], c: [1] }); 26 | expectErrors("a = array{0}, b = a & ''") 27 | .toContain('b: type'); 28 | expectDump("a = array{###} & 1 & 2, b = a delete! 1") 29 | .toEqual({ a: [1, 2], b: [2] }); 30 | expectDump("a = array{###} & 1 & 2, b? = a delete? 0") 31 | .toEqual({ a: [1, 2], b: false }); 32 | expectCompiling("a = array{###} & 1 & 2, b = a delete! 0") 33 | .toThrow('assertion failed'); 34 | expectDump("a = array{###} & 1 & 2 &&(array{###} & 3 & 4)") 35 | .toEqual({ a: [1, 2, 3, 4]}); 36 | }) 37 | 38 | test('tracked array', () => { 39 | expectDump(` 40 | a = tracked array{###} & 1 & 2 delete! 1 41 | b = tracked array{###} & 1 & 2 delete! 1 42 | c = tracked array{###} & 2 43 | t1? = a =? b 44 | t2? = a =? c 45 | `).toEqual({ 46 | a: [2], 47 | b: [2], 48 | c: [2], 49 | t1: [2], 50 | t2: false 51 | }); 52 | }) 53 | 54 | test('array at/update', () => { 55 | expectDump("a = array{###} & 1 & 2; b = a at! 1; c? = a at? 0") 56 | .toEqual({ a: [1, 2], b: 1, c: false }); 57 | expectDump("a = array{###} & 1 & 2; b = a update!(1, .value := -1)") 58 | .toEqual({ a: [1, 2], b: [-1, 2] }); 59 | expectDump(`a = array{0} &(with{+ 1})`) 60 | .toEqual({ a: [1] }); 61 | expectDump(`a = array{0} &{+ 1}`) 62 | .toEqual({ a: [1] }); 63 | // expectCompiling(`a = array{0} & with{+ 1}`) 64 | // .toThrow('expecting call argument'); 65 | expectDump(`a = array{###} & 1; b = a update!(1, .value := with{+1})`) 66 | .toEqual({ a: [1], b: [2] }); 67 | }) 68 | 69 | test('tables', () => { 70 | expectDump(` 71 | a = table{x: 0, y: ''} &() &{.x := 1, .y := 'foo'} 72 | b = a.x 73 | c = a.y`) 74 | .toEqual({ 75 | a: [{ x: 0, y: '' }, { x: 1, y: 'foo' }], 76 | b: [0, 1], 77 | c: ['', 'foo'] 78 | }); 79 | }) 80 | 81 | test('find', () => { 82 | expectDump(`a = array{###} & 1 & 2; b = a find!{=? 1}; c = b~index`) 83 | .toEqual({a: [1, 2], b: 1, c: 1}) 84 | expectDump(`a = array{###} & 1 & 2; b = a find!{=? 2}; c = b~index`) 85 | .toEqual({a: [1, 2], b: 2, c: 2}) 86 | expectDump(`a = array{###} & 1; b? = a find?{=? 0}; c? = b?~index`) 87 | .toEqual({a: [1], b: false, c: false}) 88 | expectCompiling(`a = array{###} & 1; b = a find!{=? 0}`) 89 | .toThrow('assertion failed') 90 | expectCompiling(`a = array{###} & 1; b = a find!{=? 0; 2}`) 91 | .toThrow('unused value') 92 | expectCompiling(`a = array{###} & 1; b = a find!{2}`) 93 | .toThrow('block must be conditional') 94 | }) 95 | 96 | test('for-all', () => { 97 | expectDump(`a = array{###} & 1 & 2; b = a for-all{+ 1}`) 98 | .toEqual({ a: [1, 2], b: [2, 3] }); 99 | expectDump(` 100 | a = array{###} & 1 & 2 101 | b = a for-all{ 102 | n = that 103 | record{a: n, b: n + 1} 104 | }`) 105 | .toEqual({ a: [1, 2], b: [{ a: 1, b: 2 }, { a: 2, b: 3 }] }); 106 | }) 107 | 108 | test('such-that', () => { 109 | expectDump(`a = array{###} & 1 & 2 & 3; b = a such-that{check not=? 2}`) 110 | .toEqual({ a: [1, 2, 3], b: [1, 3]}); 111 | expectDump(`a = array{###} & 1 & 2 & 3; b = a such-that{check not=? 0}`) 112 | .toEqual({ a: [1, 2, 3], b: [1, 2, 3]}); 113 | expectDump(`a = array{###} & 1 & 2 & 3; b = a such-that{check not=? 2}`) 114 | .toEqual({ a: [1, 2, 3], b: [1, 3] }); 115 | }) 116 | 117 | test('all/none', () => { 118 | expectDump(`a = array{###} & 1 & 2 & 3; b? = a all?{>? 0}`) 119 | .toEqual({ a: [1, 2, 3], b: [1, 2, 3] }); 120 | expectDump(`a = array{###} & 1 & 2 & 3; b? = a none?{ { 129 | expectDump(` 130 | a = array{###}; 131 | b = a accumulate{item: []; sum: 0; sum + item} 132 | `) 133 | .toEqual({ a: [], b: 0 }); 134 | expectDump(` 135 | a = array{###} & 1 & 2; 136 | b = a accumulate{item: []; sum: 0; sum + item} 137 | `) 138 | .toEqual({ a: [1, 2], b: 3 }); 139 | expectCompiling(` 140 | a = array{###} & 1 & 2; 141 | b = a accumulate{item: 0; sum: 0; 'foo'} 142 | `) 143 | .toThrow('input must be []'); 144 | expectCompiling(` 145 | a = array{###} & 1 & 2; 146 | b = a accumulate{item: []; sum: 0; 'foo'} 147 | `) 148 | .toThrow('result must be same type as accumulator'); 149 | }) 150 | 151 | 152 | test('selection', () => { 153 | expectDump('a: tracked array{###} & 1 & 2; s: selection{a}') 154 | .toEqual({ a: [1, 2], s: [] }); 155 | expectDump('a: tracked array{###} & 1 & 2; s: selection{a} select! 1') 156 | .toEqual({ a: [1, 2], s: [1] }); 157 | // forward selection 158 | expectDump(' s: selection{a} select! 1; a: tracked array{###} & 1 & 2') 159 | .toEqual({ a: [1, 2], s: [1] }); 160 | expectDump(` 161 | a: tracked array{###} & 1 & 2 162 | s: selection{a} select! 1 select! 2 deselect! 1 163 | `) 164 | .toEqual({ a: [1, 2], s: [2] }); 165 | expectDump(` 166 | a: tracked array{###} 167 | s = selection{a} 168 | t = selection{a} 169 | e? = s =? t 170 | `) 171 | .toEqual({ a: [], s: [], t: [], e: [] }); 172 | expectDump(` 173 | a: tracked array{###} & 1 174 | s = selection{a} select! 1 175 | t = selection{a} 176 | e? = s =? t 177 | `) 178 | .toEqual({ a: [1], s: [1], t: [], e: false }); 179 | expectErrors(` 180 | a: tracked array{###} 181 | b: tracked array{###} 182 | s = selection{a} 183 | t = selection{b} 184 | e? = s =? t 185 | `).toContain('e: type') 186 | }) 187 | 188 | test('selection synthetics', () => { 189 | expectDump(` 190 | a: tracked array{###} & 1 & 2 & 3 191 | s: selection{a} select! 2 192 | t = s.selections 193 | u = s.backing 194 | i? = s.at? 195 | `) 196 | .toEqual({ a: [1, 2, 3], s: [2], t: [2], u: [1, 2, 3], i: 2 }); 197 | }); 198 | 199 | test('selecting block', () => { 200 | expectDump(` 201 | a: tracked array{###} & 1 & 2 & 3 202 | s: selection{a} 203 | t = s selecting{>? 2} 204 | `) 205 | .toEqual({ a: [1, 2, 3], s: [], t: [3]}); 206 | }); 207 | 208 | test('selection deletion', () => { 209 | let w = compile(` 210 | a: tracked array{###} & 1 & 2 & 3 211 | s: selection{a} 212 | `) 213 | w.selectAt('s', 1); 214 | w.selectAt('s', 3); 215 | expect(w.dump()).toEqual({a: [1, 2, 3], s: [1, 3]}) 216 | w.deleteAt('a', 1); 217 | expect(w.dump()).toEqual({a: [2, 3], s: [2]}) 218 | }) 219 | 220 | test('reflexive selection', () => { 221 | let w = compile(` 222 | a: tracked table{as: selection{a}} 223 | `) 224 | w.createAt('a'); 225 | w.selectAt('a.1.as', 1) 226 | expect(w.dump()).toEqual({ 227 | a: [{ as: [1] }], 228 | }) 229 | }) 230 | 231 | test('cyclic selection', () => { 232 | let w = compile(` 233 | a: tracked table{bs: selection{b}} 234 | b: tracked table{as: selection{a}} 235 | `) 236 | w.createAt('a'); 237 | w.createAt('b'); 238 | w.selectAt('a.1.bs', 1) 239 | w.selectAt('b.1.as', 1) 240 | expect(w.dump()).toEqual({ 241 | a: [{bs: [1]}], 242 | b: [{as: [1]}], 243 | }) 244 | }) 245 | 246 | test('linking', () => { 247 | let w = compile(` 248 | a: tracked table{bs: link{b via as}} 249 | b: tracked table{as: link{a via bs}} 250 | `) 251 | w.createAt('a'); 252 | w.createAt('a'); 253 | w.createAt('b'); 254 | w.selectAt('a.1.bs', 1); 255 | w.selectAt('a.2.bs', 1); 256 | expect(w.dump()).toEqual({ 257 | a: [{ bs: [1] }, { bs: [1] }], 258 | b: [{ as: [1, 2] }], 259 | }); 260 | // update secondary link 261 | w.deselectAt('b.1.as', 1); 262 | expect(w.dump()).toEqual({ 263 | a: [{ bs: [] }, { bs: [1] }], 264 | b: [{ as: [2] }], 265 | }); 266 | }) 267 | 268 | test('reflexive link', () => { 269 | let w = compile(` 270 | t: tracked table{ 271 | a: link{t via b} 272 | b: link{t via a}} 273 | `) 274 | w.createAt('t'); 275 | w.createAt('t'); 276 | w.selectAt('t.1.a', 2); 277 | expect(w.dump()).toEqual({ 278 | t: [ 279 | { a: [2], b: [] }, 280 | { a: [], b: [1] } 281 | ]}); 282 | // update secondary link 283 | w.selectAt('t.1.b', 2); 284 | expect(w.dump()).toEqual({ 285 | t: [ 286 | { a: [2], b: [2] }, 287 | { a: [1], b: [1] } 288 | ] 289 | }); 290 | }) 291 | 292 | test('link errors', () => { 293 | expectCompiling(` 294 | a: tracked table{bs: link{b via as}} 295 | b: tracked table{as: link{a via bs}} 296 | c: a[].bs 297 | `).toThrow('link must be a field of a tracked table'); 298 | 299 | expectDump(` 300 | a: tracked table{bs: link{b via as}} 301 | b: tracked table{as: link{a via bs}} 302 | c = a[].bs 303 | `).toEqual({ 304 | a: [], 305 | b: [], 306 | c: [] 307 | }); 308 | 309 | expectCompiling(` 310 | a: tracked table{bs: link{b via as}} 311 | b: table{as: link{a via bs}} 312 | `).toThrow('link requires a tracked table'); 313 | 314 | expectCompiling(` 315 | a: tracked table{bs: link{b via foo}} 316 | b: tracked table{as: link{a via bs}} 317 | `).toThrow('Opposite link not defined'); 318 | 319 | expectCompiling(` 320 | a: tracked table{bs: link{b via foo}} 321 | b: tracked table{foo: 0, as: link{a via bs}} 322 | `).toThrow('Opposite link does not match'); 323 | }) 324 | 325 | test('selection backing update', () => { 326 | let w = compile(` 327 | a: tracked array{###} & 1 & 2 & 3 328 | s: selection{a} 329 | i? =|> s.at? 330 | `) 331 | w.selectAt('s', 2); 332 | w.writeAt('s.selections.1', 10) 333 | expect(w.dump()).toEqual({ a: [1, 10, 3], s: [2], i: 10 }) 334 | w.writeAt('s.at', 20) 335 | expect(w.dump()).toEqual({ a: [1, 20, 3], s: [2], i: 20 }) 336 | w.writeAt('i', 30) 337 | expect(w.dump()).toEqual({ a: [1, 30, 3], s: [2], i: 30 }) 338 | }) 339 | 340 | -------------------------------------------------------------------------------- /test/basic.test.ts: -------------------------------------------------------------------------------- 1 | import { expectCompiling, expectDump, expectErrors } from "./exports" 2 | test('literal outputs', () => { 3 | expectDump("a = 0, b = 'foo', c = nil, d = record{x = 0, y: 1}") 4 | .toEqual({ a: 0, b: 'foo', c: null, d: { x: 0, y: 1 } }); 5 | }); 6 | 7 | test('literal inputs', () => { 8 | expectDump("a: 0, b: 'foo', c: nil, d: record{x = 0, y: 1}") 9 | .toEqual({ a: 0, b: 'foo', c: null, d: { x: 0, y: 1 } }); 10 | }); 11 | 12 | test('references', () => { 13 | expectDump("a: 0, b: a") 14 | .toEqual({a: 0, b: 0}); 15 | expectDump("a: b, b: 0") 16 | .toEqual({a: 0, b: 0}); 17 | expectDump("a: b, b: record{x: 0, y: 1}") 18 | .toEqual({a: {x: 0, y: 1}, b: {x: 0, y: 1}}); 19 | }); 20 | 21 | test('path translation', () => { 22 | expectDump("a: b, b: record {x: 0, y: x}") 23 | .toEqual({ a: { x: 0, y: 0 }, b: { x: 0, y: 0 } }); 24 | expectDump("a: b, b: record {x: 0, y: x}", 'a.y.^reference') 25 | .toEqual('a.x') 26 | expectDump("a: b, b: record {x: 0, y: c}, c: 0", 'a.y.^reference') 27 | .toEqual('c') 28 | }); 29 | 30 | test('undefined name', () => { 31 | expectCompiling("a: c, b: 0") 32 | .toThrow('Undefined name: c'); 33 | expectCompiling("a: b.c, b: record {x: 0}") 34 | .toThrow('Undefined name: c'); 35 | }); 36 | 37 | test('circular references', () => { 38 | expectCompiling("a: a") 39 | .toThrow('Circular reference: a'); 40 | expectCompiling("a: record{x: a}") 41 | .toThrow('Circular reference: a'); 42 | expectCompiling("a: b, b: a") 43 | .toThrow('Circular reference: a'); 44 | }); 45 | 46 | test('do block', () => { 47 | expectDump("a = do{1}") 48 | .toEqual({ a: 1 }); 49 | expectDump("a = do{check 1; 2}") 50 | .toEqual({ a: 2 }); 51 | expectDump("a = do{1; that}") 52 | .toEqual({ a: 1 }); 53 | expectDump("a = do{record{x: 0}; .x}") 54 | .toEqual({ a: 0 }); 55 | expectDump("a = 0; b = that") 56 | .toEqual({ a: 0, b: 0 }); 57 | expectDump("a = 0; b = with{that}") 58 | .toEqual({ a: 0, b: 0 }); 59 | }); 60 | 61 | test('statement skipping', () => { 62 | expectCompiling("a = do{1; 2}") 63 | .toThrow('unused value'); 64 | expectDump("a = do{1; check 2}") 65 | .toEqual({ a: 1 }); 66 | expectCompiling("a = do{check 1; check 2}") 67 | .toThrow('code block has no result'); 68 | expectCompiling("a = do{1; let x = + 2}") 69 | .toThrow('unused value'); 70 | expectDump("a = do{1; let x = 2; + x}") 71 | .toEqual({ a: 3 }); 72 | expectDump("a = do{1; let x = + 2; x}") 73 | .toEqual({ a: 3 }); 74 | expectCompiling("a = do{1; let x = + 2; 3}") 75 | .toThrow('unused value'); 76 | }) 77 | 78 | test('update', () => { 79 | expectDump("a = record{x: 0, y : 0}, b = .x := 1") 80 | .toEqual({ a: {x: 0, y: 0}, b: {x: 1, y: 0}}); 81 | expectDump("a = record{x: 0, y : 0}, b = a with{.x := 1}") 82 | .toEqual({ a: {x: 0, y: 0}, b: {x: 1, y: 0}}); 83 | expectDump(` 84 | a = record{x: 0, y : record{i: 0, j: 0}} 85 | b = .y := with{.i := 1} 86 | `) 87 | .toEqual({ 88 | a: { x: 0, y: { i: 0, j: 0 } }, 89 | b: { x: 0, y: { i: 1, j: 0 } } 90 | }); 91 | expectDump(` 92 | a = record{x: 0, y : record{i: 0, j: 0}} 93 | b = .y := with{.i := + 1} 94 | `) 95 | .toEqual({ 96 | a: { x: 0, y: { i: 0, j: 0 } }, 97 | b: { x: 0, y: { i: 1, j: 0 } }, 98 | }); 99 | expectCompiling("a = record{x = 0, y : 0}, b = .x := 1") 100 | .toThrow('not updatable'); 101 | expectErrors("a = record{x: 0, y : 0}, b = .x := 'foo'") 102 | .toContain('b: type'); 103 | }); 104 | 105 | test('update reference translation', () => { 106 | expectDump(` 107 | a = record{x: 0, y : record{i: 0, j = x}} 108 | b = .y := with{.i := + 1} 109 | c = .x := 1 110 | `) 111 | .toEqual({ 112 | a: { x: 0, y: { i: 0, j: 0 } }, 113 | b: { x: 0, y: { i: 1, j: 0 } }, 114 | c: { x: 1, y: { i: 1, j: 1 } } 115 | }); 116 | }); 117 | 118 | test('call', () => { 119 | expectDump("f = do{x: 0}, a = 1, b = f()") 120 | .toEqual({ f: 0, a: 1, b: 1 }); 121 | expectErrors("f = do{x: ''}, a = 1, b = f()") 122 | .toContain('b: type'); 123 | expectDump("f = do{x: 0; y: x}, a = 1, b = f(2)") 124 | .toEqual({ f: 0, a: 1, b: 2 }); 125 | expectDump("f = do{x: 0; y: x}, a = 1, b = f 2") 126 | .toEqual({ f: 0, a: 1, b: 2 }); 127 | expectDump("f = do{x: 0; y: x}, a = 1, b = f(.y := 2)") 128 | .toEqual({ f: 0, a: 1, b: 2 }); 129 | expectCompiling("f = 0, a = 1, b = f()") 130 | .toThrow('Can only call a do-block'); 131 | expectCompiling("f = do{x = 0}, a = 1, b = f()") 132 | .toThrow('function input not defined'); 133 | expectCompiling("f = do{x: 0; y: x}, a = 1, b = f(.z := 2)") 134 | .toThrow('Undefined name'); 135 | expectCompiling("f = do{x: 0; y: x}, a = 1, b = f(.y := 2, 2)") 136 | .toThrow('Only first argument can be anonymous'); 137 | }); 138 | 139 | test('formula', () => { 140 | expectDump("f = do{x: 0}, a = 1, b = a f()") 141 | .toEqual({ f: 0, a: 1, b: 1}); 142 | expectDump("f = do{x: 0; y: x}, a = 1, b = a f()") 143 | .toEqual({ f: 0, a: 1, b: 1 }); 144 | expectDump("f = do{x: 0; y: x}, a = 1, b = a f(2)") 145 | .toEqual({ f: 0, a: 1, b: 2 }); 146 | expectDump("f = do{x: 0; y: x}, a = 1, b = a f 2") 147 | .toEqual({ f: 0, a: 1, b: 2 }); 148 | expectDump("f = do{x: 0; y: x}, a = 1, b = a f 2 f 3") 149 | .toEqual({ f: 0, a: 1, b: 3 }); 150 | }); 151 | 152 | test('arithmetic', () => { 153 | expectDump("a = 1 + 2") 154 | .toEqual({ a: 3 }); 155 | expectDump("a = 1 +(2)") 156 | .toEqual({ a: 3 }); 157 | expectErrors("a = '' + 0") 158 | .toContain('a: type'); 159 | expectErrors("a = 1 + ''") 160 | .toContain('a: type'); 161 | expectErrors("a = 1 + ''; b = a") 162 | .toContain('b: type'); 163 | expectDump("a = 1 + 2 * 3") 164 | .toEqual({ a: 9 }); 165 | expectDump("a = 1 + (2 * 3)") 166 | .toEqual({ a: 7 }); 167 | }); 168 | 169 | test('leading infix operator', () => { 170 | expectDump("c: 0, f = + 32") 171 | .toEqual({ c: 0, f: 32 }); 172 | expectDump("c: 0, f = * 1.8 + 32") 173 | .toEqual({ c: 0, f: 32 }); 174 | }) 175 | 176 | test('conditionals', () => { 177 | expectDump("a? = 'foo' =? 'foo'") 178 | .toEqual({ a: 'foo' }) 179 | expectDump("a? = 'foo' =? 'bar'") 180 | .toEqual({ a: false }) 181 | expectDump("a? = 1 >? 0") 182 | .toEqual({ a: 0 }) 183 | expectDump("a? = 0 >? 1") 184 | .toEqual({ a: false }) 185 | expectCompiling("a = 1 >? 0") 186 | .toThrow('conditional field name must have suffix ?') 187 | expectCompiling("a? = 0") 188 | .toThrow('unconditional field name cannot have suffix ?') 189 | expectCompiling("a: 1 >? 0") 190 | .toThrow('input fields must be unconditional') 191 | }) 192 | 193 | test('guarded references', () => { 194 | expectDump("a? = 1 >? 0, b? = a?") 195 | .toEqual({ a: 0, b: 0 }) 196 | expectDump("a? = 0 >? 1, b? = a?") 197 | .toEqual({ a: false, b: false }) 198 | expectCompiling("a? = 1 >? 0, b = a") 199 | .toThrow('conditional reference lacks suffix ? or !') 200 | expectCompiling("a = 1, b = a?") 201 | .toThrow('invalid reference suffix ?') 202 | expectCompiling("a? = 1 >? 0, b = a?") 203 | .toThrow('conditional field name must have suffix ?') 204 | expectCompiling("a? = 1 >? 0, b? = 0 a()") 205 | .toThrow('conditional reference lacks suffix ? or !') 206 | expectCompiling("a? = 1 >? 0, b? = + 1") 207 | .toThrow('Previous value is conditional') 208 | expectDump("a? = 1 >? 0 >? -1") 209 | .toEqual({ a: -1}) 210 | expectDump("x = record{a? = 1 >? 0, b? = a?}, y? = x.a?") 211 | .toEqual({ x: { a: 0, b: 0 }, y: 0 }) 212 | }) 213 | 214 | test('assertions', () => { 215 | expectDump("a = 1 >! 0") 216 | .toEqual({ a: 0 }); 217 | expectCompiling("a = 0 >! 1") 218 | .toThrow('assertion failed: >!') 219 | expectCompiling("x = record{a? = 0 >? 1}, y = x.a!") 220 | .toThrow('assertion failed: a!') 221 | }) 222 | 223 | test('blank', () => { 224 | expectDump("a? = '' blank?()") 225 | .toEqual({ a: '' }); 226 | expectDump("a? = 'foo' blank?()") 227 | .toEqual({ a: false }); 228 | }) 229 | 230 | test('try', () => { 231 | expectDump("a = try {0 >? 1} else {2}") 232 | .toEqual({a: 2}) 233 | expectDump("a = try {1 >? 0} else {2}") 234 | .toEqual({a: 0}) 235 | expectCompiling("a = try {0 >? 1} else {0 >? 1}") 236 | .toThrow('try failed') 237 | expectDump("a? = try {0 >? 1} else reject") 238 | .toEqual({ a: false }) 239 | expectCompiling("a = try {0} else {0 >? 1}") 240 | .toThrow('clause must be conditional if not last') 241 | expectCompiling("a? = try {0} else reject") 242 | .toThrow('clause must be conditional if not last') 243 | expectErrors("a = try {0 >? 1} else {'foo'}") 244 | .toContain('a: type') 245 | expectErrors("a = try {0 ? 1} else {+ 2}") 250 | .toEqual({ a: 2 }) 251 | }) 252 | 253 | test('recursion', () => { 254 | expectDump(` 255 | fac = do{n: 0; try {check n <=? 0; 1} else {n - 1 fac() * n}}, 256 | x = 1 fac() 257 | `) 258 | .toEqual({ fac: 1, x: 1 }); 259 | expectDump(` 260 | fac = do{n: 1; try {check n <=? 0; 1} else {n - 1 fac() * n}}, 261 | x = 1 fac() 262 | `) 263 | .toEqual({ fac: 1, x: 1 }); 264 | expectDump(` 265 | fac = do{n: 0; try {check n <=? 0; 1} else {n - 1 fac() * n}}, 266 | x = 4 fac() 267 | `) 268 | .toEqual({ fac: 1, x: 24 }); 269 | expectCompiling("fac = do{n: 0, n fac()}") 270 | .toThrow('Recursive call outside secondary try clause'); 271 | expectCompiling("fac = do{n: 0, try {n fac()}}") 272 | .toThrow('Recursive call outside secondary try clause'); 273 | expectCompiling("fac = do{n: 0; try {check 0 { 278 | expectDump(` 279 | even? = do{n: 0; try{n =? 0} else { check n - 1 odd?(); n} else reject} 280 | odd? = do{n:1; check n not=? 0; check n - 1 even?(); n} 281 | x? = 1 even?() 282 | y? = 2 odd?() 283 | `) 284 | .toEqual({ even: 0, odd: 1, x: false, y: false }); 285 | expectDump(` 286 | even? = do{n: 0; try{n =? 0} else { check n - 1 odd?(); n} else reject} 287 | odd? = do{n:1; check n not=? 0; check n - 1 even?(); n} 288 | x? = 2 even?() 289 | y? = 3 odd?() 290 | `) 291 | .toEqual({ even: 0, odd: 1, x: 2, y: 3 }); 292 | }); 293 | 294 | test('dynamic input defaults', () => { 295 | expectDump("f = do{x:0, y: x + 1}, a = 1 f()") 296 | .toEqual({f: 1, a: 2}) 297 | expectDump("f = do{x:0, y: x + 1}, a = 1 f(+ 1)") 298 | .toEqual({f: 1, a: 3}) 299 | expectDump("f = do{x:0, y: x + 1}, a = 1 f(+ 1)") 300 | .toEqual({f: 1, a: 3}) 301 | }) 302 | 303 | test('generics', () => { 304 | expectDump("a? = 1 =? 2") 305 | .toEqual({a: false}); 306 | expectErrors("a? = 1 =? ''") 307 | .toContain('a: type'); 308 | expectDump(` 309 | a = record{x: 0, y: ''} 310 | b = a 311 | c? = a =? b 312 | `) 313 | .toEqual({ 314 | a: { x: 0, y: '' }, 315 | b: { x: 0, y: '' }, 316 | c: { x: 0, y: '' } 317 | }); 318 | expectDump(` 319 | a = record{x: 0, y: ''} 320 | b = a with{.x := 1} 321 | c? = a =? b 322 | `) 323 | .toEqual({ 324 | a: { x: 0, y: '' }, 325 | b: { x: 1, y: '' }, 326 | c: false 327 | }); 328 | expectErrors(` 329 | a = record{x: 0, y: ''} 330 | b = record{x: 0, y: ''} 331 | c? = a =? b 332 | `) 333 | .toContain('c: type') 334 | }); 335 | 336 | test('choices', () => { 337 | expectDump("a: choice{x?: 1, y?: ''}") 338 | .toEqual({ a: { x: 1 } }); 339 | expectDump("a = choice{x?: 1, y?: ''}; b = #y('foo')") 340 | .toEqual({ a: { x: 1 }, b: { y: 'foo' } }); 341 | expectDump("a = choice{x?: 1, y?: ''}; b = with{#y('foo')}") 342 | .toEqual({ a: { x: 1 }, b: { y: 'foo' } }); 343 | expectDump("a = choice{x?: 1, y?: ''}; b = #y('foo')") 344 | .toEqual({ a: { x: 1 }, b: { y: 'foo' } }); 345 | expectDump("a = choice{x?: 1, y?: ''}; b = a #y('foo')") 346 | .toEqual({ a: { x: 1 }, b: { y: 'foo' } }); 347 | expectDump("a = choice{x?: 1, y?: ''}; b = a #y()") 348 | .toEqual({ a: { x: 1 }, b: { y: '' } }); 349 | expectDump("a = choice{x?: 1, y?: ''}; b = a #x(+ 1); c = b #x()") 350 | .toEqual({ a: { x: 1 }, b: { x: 2 }, c: {x: 1} }); 351 | expectDump("a = choice{x?: 1, y?: ''}; b = a #x(+ 1); c = b #x(+ 1)") 352 | .toEqual({ a: { x: 1 }, b: { x: 2 }, c: { x: 2 } }); 353 | expectDump("a = choice{x?: 1, y?: ''}; b = a #x(+ 1); c = with{.x! := + 1}") 354 | .toEqual({ a: { x: 1 }, b: { x: 2 }, c: { x: 3 } }); 355 | expectCompiling("a: choice{x: 1, y?: ''}") 356 | .toThrow('Option names must end in ?'); 357 | expectCompiling("a: choice{x? = 1, y?: ''}") 358 | .toThrow('Option must be an input (:)'); 359 | expectCompiling("a = choice{x?: 1, y?: ''}; b = a #x('foo')") 360 | .toThrow('changing type of value'); 361 | expectCompiling("a = choice{x?: 1, y?: ''}; b = a #z('foo')") 362 | .toThrow('no such option'); 363 | expectDump("a: choice{x?: 1, y?: ''}; b? = a.x?") 364 | .toEqual({ a: { x: 1 }, b: 1 }); 365 | expectDump("a: choice{x?: 1, y?: ''}; b? = a.y?") 366 | .toEqual({ a: { x: 1 }, b: false }); 367 | expectCompiling("a: choice{x?: 1, y?: ''}, b? = a.x") 368 | .toThrow('conditional reference lacks suffix ?'); 369 | }) 370 | 371 | test('recursive choices', () => { 372 | expectDump("a: choice{x?: 1, y?: a}") 373 | .toEqual({ a: { x: 1 } }); 374 | expectDump("a = choice{x?: 1, y?: a}") 375 | .toEqual({ a: { x: 1 } }); 376 | expectCompiling("a: choice{x?: a, y?: 1}") 377 | .toThrow('Circular reference'); 378 | expectDump("a: choice{x?: 1, y?: a}; b = a #y()") 379 | .toEqual({ a: { x: 1 }, b: { y: { x: 1 }} }); 380 | expectDump("a: choice{x?: 1, y?: b}, b: choice{z?: 1, w?: a}") 381 | .toEqual({ a: { x: 1 }, b: { z: 1 } }); 382 | expectDump(` 383 | a: choice{x?: 1, y?: b} 384 | b: choice{z?: 1, w?: a} 385 | c = a #y() 386 | `) 387 | .toEqual({ a: { x: 1 }, b: { z: 1 }, c: { y: { z: 1 } } }); 388 | expectDump(` 389 | a: choice{x?: 1, y?: b} 390 | b: choice{z?: 1, w?: a} 391 | c = a #y(#w()) 392 | `) 393 | .toEqual({ 394 | a: { x: 1 }, 395 | b: { z: 1 }, 396 | c: { y: { w: { x: 1 } } } 397 | }); 398 | expectDump(` 399 | a: choice{x?: 1, y?: b} 400 | b: choice{z?: 1, w?: a} 401 | c = a #y(b) 402 | `) 403 | .toEqual({ 404 | a: { x: 1 }, 405 | b: { z: 1 }, 406 | c: { y: { z: 1 } } 407 | }); 408 | expectCompiling("a: choice{x?: b, y?: 1}, b: choice{x?: a, y?: 1}") 409 | .toThrow('Circular reference'); 410 | }); 411 | 412 | test('exports', () => { 413 | expectDump("a = do{1; export 2}, b = a~") 414 | .toEqual({ a: 1, b: 2 }) 415 | // implicit export 416 | expectDump("a = do{do{1; export 2}}, b = a~") 417 | .toEqual({ a: 1, b: 2 }) 418 | // named export 419 | expectDump("a = do{1; export foo = 2}, b = a~") 420 | .toEqual({ a: 1, b: { foo: 2 } }) 421 | // call export 422 | expectDump("f = do{n: 0; export nil}; b = 1 f(); c = b~") 423 | .toEqual({ f: 0, b: 1, c: null }) 424 | // builtin export 425 | expectDump("a = 1.5 truncate(), b = a~fraction") 426 | .toEqual({ a: 1, b: .5 }) 427 | // try exports 428 | expectDump(` 429 | a = do { 430 | try 431 | clause1? = {1 >? 0; export 2 } 432 | clause2? = else {1; export 'foo'}} 433 | b = a~ 434 | `) 435 | .toEqual({ a: 0, b: {clause1: 2} }) 436 | expectDump(` 437 | a = do { 438 | try 439 | clause1? = {0 >? 1; export 2 } 440 | clause2? = else {2; export 'foo'}} 441 | b = a~ 442 | `) 443 | .toEqual({ a: 2, b: {clause2: 'foo'} }) 444 | expectDump(` 445 | a = do { 446 | try 447 | clause1? = {0 >? 1; export 2 } 448 | clause2? = else {2; export 'foo'}} 449 | b? = a~clause2? 450 | `) 451 | .toEqual({ a: 2, b: 'foo' }) 452 | expectDump(` 453 | a = do { 454 | try 455 | clause1? = {0 >? 1; export 2 } 456 | clause2? = else {2; export 'foo'}} 457 | b? = a~clause1? 458 | `) 459 | .toEqual({ a: 2, b: false }) 460 | }) 461 | 462 | test('conditional export', () => { 463 | expectDump("a? = do{ 1 >? 0; export 2}; b? = a?~") 464 | .toEqual({a: 0, b: 2}) 465 | expectDump("a? = do{ 1 >? 2; export 2}; b? = a?~") 466 | .toEqual({a: false, b: false}) 467 | expectCompiling("a? = do{ 1 >? 2; export 2}; b? = a~?") 468 | .toThrow('? or ! goes before ~ not after') 469 | expectCompiling("a? = do{ 1 >? 2; export 2}; b? = a~") 470 | .toThrow('conditional reference lacks suffix ? or !') 471 | }) 472 | 473 | test('recursive export', () => { 474 | 475 | // recursive try export with anon export 476 | expectDump(` 477 | a = do{ 478 | n: 1 479 | try 480 | base?= {n { 554 | expectDump(` 555 | e = record{a: 0} extend{b = a + 1} 556 | f = with{.a := 1}`) 557 | .toEqual({ 558 | e: { a: 0, b: 1 }, 559 | f: { a: 1, b: 2 } 560 | }) 561 | }) -------------------------------------------------------------------------------- /test/edit.test.ts: -------------------------------------------------------------------------------- 1 | import { compile } from './exports'; 2 | const NaN = Number.NaN; 3 | 4 | test('nochange', () => { 5 | let w = compile(` 6 | a:: 0 7 | as:: array{0} 8 | `); 9 | w.editAt('', `::nochange`) 10 | expect(w.dump()).toEqual({ a: 0, as: [] }); 11 | }); 12 | 13 | test('replace', () => { 14 | let w = compile(` 15 | a:: 0 16 | as:: array{0} 17 | `); 18 | w.createAt('as') 19 | w.editAt('a', `::replace ''`) 20 | expect(w.dump()).toEqual({ a: '', as: [0] }); 21 | w.editAt('as.0', `::replace ''`); 22 | expect(w.dump()).toEqual({ a: '', as: [''] }); 23 | }); 24 | 25 | test('replace by reference', () => { 26 | let w = compile(` 27 | a:: '' 28 | one = 1 29 | `); 30 | w.editAt('a', `::replace .one`) 31 | expect(w.dump()).toEqual({ a: 1, one: 1 }); 32 | }); 33 | 34 | test('append', () => { 35 | let w = compile(` 36 | a:: record{x:: 0} 37 | as:: table{x:: 0} 38 | `); 39 | w.createAt('as') 40 | w.editAt('', `::append{y:: ''}`) 41 | w.editAt('a', `::append{y:: ''}`) 42 | w.editAt('as.0', `::append{y:: ''}`); 43 | expect(w.dump()).toEqual({ a: { x: 0, y: '' }, as: [{x: 0, y: ''}], y: '' }); 44 | }); 45 | 46 | test('insert', () => { 47 | let w = compile(` 48 | a:: record{x:: 0} 49 | as:: table{x:: 0} 50 | `); 51 | w.createAt('as') 52 | w.editAt('a', `::insert{y:: ''}`) 53 | w.editAt('a.x', `::insert{y:: ''}`) 54 | w.editAt('as.0.x', `::insert{y:: ''}`); 55 | expect(w.dump()).toEqual({ a: { x: 0, y: '' }, as: [{x: 0, y: ''}], y: '' }); 56 | }); 57 | 58 | test('convert number to text', () => { 59 | let w = compile(` 60 | a:: 0 61 | as:: array{0} 62 | `); 63 | w.createAt('as') 64 | w.editAt('a', `::convert ''`) 65 | w.editAt('as.0', `::convert ''`); 66 | expect(w.dump()).toEqual({ a: '0', as: ['0'] }); 67 | }); 68 | 69 | test('convert text to number', () => { 70 | let w = compile(` 71 | a:: '0' 72 | as:: array{'0'} 73 | `); 74 | w.createAt('as') 75 | w.editAt('a', `::convert 0`) 76 | w.editAt('as.0', `::convert 0`); 77 | expect(w.dump()).toEqual({ a: 0, as: [0] }); 78 | }); 79 | 80 | test('conversion error', () => { 81 | let w = compile(` 82 | a:: 'a' 83 | as:: array{'a'} 84 | `); 85 | w.createAt('as') 86 | w.editAt('a', `::convert 0`) 87 | expect(w.editErrorMessages).toContain('a: conversion'); 88 | w.editAt('as.0', `::convert 0`); 89 | expect(w.dump()).toEqual({ a: NaN, as: [NaN] }); 90 | expect(w.editErrorMessages).toContain('a: conversion'); 91 | expect(w.editErrorMessages).toContain('as.0: conversion'); 92 | expect(w.editErrorMessages).toContain('as.1: conversion'); 93 | }); 94 | 95 | test('write resets conversion error', () => { 96 | let w = compile(`a:: 'a'`); 97 | w.editAt('a', `::convert 0`) 98 | expect(w.dump()).toEqual({ a: NaN }); 99 | w.writeAt('a', 1) 100 | expect(w.dump()).toEqual({ a: 1 }); 101 | expect(w.editErrorMessages).toEqual([]); 102 | }); 103 | 104 | test('conversion type error', () => { 105 | let w = compile(`a:: 0, b = a + 1`); 106 | w.editAt('a', `::convert ''`) 107 | expect(w.editErrorMessages).toContain('b: type'); 108 | w.editAt('a', `::convert 0`) 109 | expect(w.editErrorMessages).toEqual([]); 110 | expect(w.dump()).toEqual({ a: 0, b: 1 }); 111 | }); 112 | 113 | test('reference error', () => { 114 | let w = compile(`a:: record{x: 0}, b = a.x`); 115 | w.editAt('a', `::convert 0`) 116 | expect(w.editErrorMessages).toContain('b: reference'); 117 | expect(w.dump()).toEqual({ a: 0, b: null }); 118 | }); 119 | 120 | test('delete', () => { 121 | let w = compile(`a:: 0, b = a + 1`); 122 | w.editAt('a', `::delete`) 123 | expect(w.editErrorMessages).toContain('b: reference'); 124 | expect(w.dump()).toEqual({ b: 1 }); 125 | }); 126 | 127 | test('move', () => { 128 | let w = compile(`a:: '', b:: 1, c = b + 1`); 129 | w.editAt('a', `::move .b`) 130 | expect(w.dump()).toEqual({ a: 1, c: 2 }); 131 | }); 132 | 133 | test('move record', () => { 134 | let w = compile(`a:: '', b:: record{x:: 1}, c = b.x + 1`); 135 | w.editAt('a', `::move .b`) 136 | expect(w.dump()).toEqual({ a: { x: 1 }, c: 2 }); 137 | }); 138 | 139 | test('move dependent ref', () => { 140 | let w = compile(`a:: '', b:: record{x:: 1}, c = .x + 1`); 141 | expect(w.dump()).toEqual({ a: '', b: { x: 1 }, c: 2 }); 142 | w.editAt('a', `::move .b`) 143 | expect(w.dump()).toEqual({ a: { x: 1 }, c: 2 }); 144 | }); 145 | 146 | test('move within dependent ref', () => { 147 | let w = compile(`a:: record{x:: '', y:: 1}, c = .y + 1`); 148 | expect(w.dump()).toEqual({ a: { x: '', y: 1 }, c: 2 }); 149 | w.editAt('a.x', `::move .a.y`) 150 | expect(w.dump()).toEqual({ a: { x: 1 }, c: 2 }); 151 | }); 152 | 153 | test('move-insert', () => { 154 | let w = compile(`a:: '', b:: 1, c = b + 1`); 155 | w.editAt('a', `::move-insert .b`) 156 | expect(w.dump()).toEqual({ b: 1, a: '', c: 2 }); 157 | }); 158 | 159 | test('array move', () => { 160 | let w = compile(`a:: table{x:: '', y:: 0, z = y + 1}`); 161 | w.createAt('a'); 162 | w.writeAt('a.1.y', 1); 163 | w.createAt('a'); 164 | w.writeAt('a.2.y', 2); 165 | w.editAt('a.0.x', `::move .a[].y`) 166 | expect(w.dump()).toEqual({ a: [{ x: 1, z: 2 }, {x: 2, z: 3}] }); 167 | }); 168 | 169 | test('wrap record', () => { 170 | let w = compile(`a:: 0, as:: array{0}`); 171 | w.createAt('as') 172 | w.editAt('a', `::wrap-record`) 173 | w.editAt('as.0', `::wrap-record`) 174 | expect(w.dump()).toEqual({ a: { value:0 }, as: [{value: 0}]}); 175 | }); 176 | 177 | test('unwrap record', () => { 178 | let w = compile(`a:: record{x:: 0}, as:: table{x:: 0}`); 179 | w.createAt('as') 180 | w.editAt('a', `::unwrap`) 181 | w.editAt('as.0', `::unwrap`) 182 | expect(w.dump()).toEqual({ a:0, as: [0]}); 183 | }); 184 | 185 | test('wrap array', () => { 186 | let w = compile(`a:: 0, as:: array{0}`); 187 | w.createAt('as') 188 | w.createAt('as') 189 | w.writeAt('as.2', 1); 190 | w.editAt('a', `::wrap-array`) 191 | w.editAt('as.0', `::wrap-array`) 192 | expect(w.dump()).toEqual({ a: [0], as: [[0], [1]] }); 193 | w.createAt('as.2'); 194 | expect(w.dump()).toEqual({ a: [0], as: [[0], [1, 0]] }); 195 | }); 196 | 197 | test('unwrap array', () => { 198 | let w = compile(`a:: array{0}, as:: array{array{0}}`); 199 | w.createAt('a') 200 | w.createAt('as') 201 | w.createAt('as.1') 202 | w.editAt('a', `::unwrap`) 203 | w.editAt('as.0', `::unwrap`) 204 | expect(w.dump()).toEqual({ a: 0, as: [0] }); 205 | }); 206 | 207 | test('unwrap empty array', () => { 208 | let w = compile(`a:: array{0}, as:: array{array{0}}`); 209 | w.editAt('a', `::unwrap`) 210 | w.editAt('as.0', `::unwrap`) 211 | expect(w.dump()).toEqual({ a: 0, as: [] }); 212 | expect(w.editErrorMessages).toContain('a: conversion'); 213 | expect(w.editErrorMessages).toContain('as.0: conversion'); 214 | }); 215 | 216 | -------------------------------------------------------------------------------- /test/exports.ts: -------------------------------------------------------------------------------- 1 | import { Workspace } from "../src/exports"; 2 | 3 | // constants for dump of boolean choices 4 | export const no = { no: null } 5 | export const yes = { yes: null } 6 | export const off = { off: null } 7 | export const on = { on: null } 8 | 9 | /** Compile a workspace from source */ 10 | export function compile(source: string) { 11 | return Workspace.compile(source); 12 | } 13 | 14 | /** Compile and dump at a location to plain JS object */ 15 | export function expectDump(source: string, at = '') { 16 | return expect(compile(source).dumpAt(at)); 17 | } 18 | 19 | /** Test compiler exceptions */ 20 | export function expectCompiling(source: string) { 21 | return expect(() => compile(source)); 22 | } 23 | 24 | /** Test array of static errors */ 25 | export function expectErrors(source: string) { 26 | return expect(compile(source).editErrorMessages); 27 | } 28 | -------------------------------------------------------------------------------- /test/update.test.ts: -------------------------------------------------------------------------------- 1 | import { compile, expectCompiling, expectErrors, off } from './exports'; 2 | 3 | test('write update', () => { 4 | let w = compile("a: 0"); 5 | w.writeAt('a', 1); 6 | expect(w.dumpAt('a')).toEqual(1); 7 | expect(() => { w.writeAt('a', 'foo') }).toThrow('edit error: type') 8 | }); 9 | 10 | test('choice update', () => { 11 | let w = compile("a: choice{x?: 0; y?: 'foo'}"); 12 | w.updateAt('a', '#y()'); 13 | w.writeAt('a.y', 'bar'); 14 | expect(w.dumpAt('a')).toEqual({y: 'bar'}); 15 | }); 16 | 17 | test('create update', () => { 18 | let w = compile("a: array{0}"); 19 | w.createAt('a'); 20 | expect(w.dumpAt('a')).toEqual([0]); 21 | }); 22 | 23 | test('delete update', () => { 24 | let w = compile("a: array{###} & 1 & 2"); 25 | w.deleteAt('a', 1); 26 | expect(w.dumpAt('a')).toEqual([2]); 27 | }); 28 | 29 | test('update readonly', () => { 30 | let w = compile("a = 0"); 31 | expect(() => { w.writeAt('a', 1) }).toThrow('not updatable') 32 | }); 33 | 34 | test('update type check', () => { 35 | let w = compile("a: 0"); 36 | expect(() => { w.writeAt('a', 'foo') }).toThrow('edit error: type') 37 | }); 38 | 39 | test('interface', () => { 40 | let w = compile("c: 0, f =|> c * 1.8 + 32 on-update{write - 32 / 1.8 -> c}"); 41 | expect(w.dumpAt('f')).toEqual(32); 42 | w.writeAt('f', 212); 43 | expect(w.dumpAt('c')).toEqual(100); 44 | }); 45 | 46 | test('incrementer', () => { 47 | let w = compile(` 48 | c: 0 49 | button =|> off on-update{write c <- +1}`); 50 | w.turnOn('button'); 51 | expect(w.dumpAt('c')).toEqual(1); 52 | }); 53 | 54 | test('update error', () => { 55 | let w = compile(` 56 | c: 0 57 | button =|> off on-update{write c <- ''}`); 58 | expect(()=>w.turnOn('button')).toThrow('edit error: type') 59 | }); 60 | 61 | test('internal incrementer', () => { 62 | let w = compile(` 63 | r = record { 64 | c: 0 65 | button =|> off on-update{write c <- + 1} 66 | } 67 | s = r with{.button := #on()} 68 | `); 69 | expect(w.dumpAt('s')).toEqual({c: 1, button: off}); 70 | }); 71 | 72 | test('internal update error', () => { 73 | expectErrors(` 74 | r = record { 75 | c: 0 76 | button =|> off on-update{write c <- ''} 77 | } 78 | s = r with{.button := #on()} 79 | `).toContain('s: type') 80 | }); 81 | 82 | test('function on-update', () => { 83 | let w = compile(` 84 | c: 0, 85 | f = do { 86 | in: 0 87 | + 1 88 | on-update { write 1 -> in} 89 | } 90 | g =|> c f() 91 | `); 92 | expect(w.dumpAt('g')).toEqual(1); 93 | w.writeAt('g', 212); 94 | expect(w.dumpAt('c')).toEqual(1); 95 | }); 96 | 97 | test('reverse formula', () => { 98 | let w = compile("c: 0, f =|> c * 1.8 + 32"); 99 | expect(w.dumpAt('f')).toEqual(32); 100 | w.writeAt('f', 212); 101 | expect(w.dumpAt('c')).toEqual(100); 102 | }); 103 | 104 | test('literal update', () => { 105 | expect(() => compile("c: 0, f =|> 0")) 106 | .toThrow('not updatable'); 107 | }); 108 | 109 | test('constant formula update', () => { 110 | expect(() => compile("c: 0, f =|> 0 * 1.8 + 32")) 111 | .toThrow('not updatable'); 112 | }); 113 | 114 | test('update propagation', () => { 115 | let w = compile(` 116 | s = record { 117 | c: 0; 118 | f =|> c * 1.8 + 32 on-update{write - 32 / 1.8 -> c} 119 | } 120 | t = .f := 212 121 | u = s with{.f := 212}`); 122 | expect(w.dumpAt('t')).toEqual({c: 100, f: 212}); 123 | expect(w.dumpAt('u')).toEqual({c: 100, f: 212}); 124 | }); 125 | 126 | test('internal reverse formula', () => { 127 | let w = compile(` 128 | s = record { 129 | c: 0; 130 | f =|> c * 1.8 + 32 131 | } 132 | t = .f := 212 133 | u = s with{.f := 212}`); 134 | expect(w.dumpAt('s')).toEqual({c: 0, f: 32}); 135 | expect(w.dumpAt('t')).toEqual({c: 100, f: 212}); 136 | expect(w.dumpAt('u')).toEqual({c: 100, f: 212}); 137 | }); 138 | 139 | test('write type check', () => { 140 | expectErrors("c: 0, f =|> c on-update{write 'foo' -> c}") 141 | .toContain('f.^code.6.7: type'); 142 | }); 143 | 144 | test('write order check', () => { 145 | expectCompiling("c: 0, f =|> c on-update{write -> g}, g: 0") 146 | .toThrow('write must go backwards'); 147 | }); 148 | 149 | test('write context check', () => { 150 | expectCompiling(` 151 | a: 0 152 | s = record { 153 | c: 0 154 | f =|> c on-update{write -> a} 155 | } 156 | t = s with{.f := 1} 157 | `).toThrow('write outside context of update'); 158 | expectCompiling(` 159 | a: 0 160 | s = record { 161 | c: 0 162 | f =|> a 163 | } 164 | t = s with{.f := 1} 165 | `).toThrow('write outside context of update'); 166 | }); 167 | 168 | test('conditional on-update', () => { 169 | let w = compile(` 170 | c: 0 171 | f =|> c * 1.8 + 32 on-update{ 172 | try { 173 | check 1 =? 2 174 | write 50 -> c 175 | } 176 | else { 177 | write - 32 / 1.8 -> c 178 | } 179 | }`); 180 | w.writeAt('f', 212); 181 | expect(w.dumpAt('c')).toEqual(100); 182 | }); 183 | 184 | test('conditional on-update 2', () => { 185 | let w = compile(` 186 | c: 0 187 | f =|> c * 1.8 + 32 on-update{ 188 | try { 189 | check 1 not=? 2 190 | write 50 -> c 191 | } 192 | else { 193 | write - 32 / 1.8 -> c 194 | } 195 | }`); 196 | w.writeAt('f', 212); 197 | expect(w.dumpAt('c')).toEqual(50); 198 | }); 199 | 200 | test('conditional on-update merging writes', () => { 201 | let w = compile(` 202 | c: record{x: 0, y: 0} 203 | f =|> c.x on-update{ 204 | try { 205 | check 1 not=? 2 206 | write c <- with{.x := 1} 207 | nil 208 | } 209 | else { 210 | write 1 -> c.x 211 | nil 212 | } 213 | }`); 214 | w.writeAt('f', 1); 215 | expect(w.dumpAt('c.x')).toEqual(1); 216 | }); 217 | 218 | test('conditional on-update in update', () => { 219 | // tricky because of analysis deferral 220 | let w = compile(` 221 | s = record { 222 | c: 0 223 | f =|> c * 1.8 + 32 on-update{ 224 | try { 225 | check 1 =? 2 226 | write 50 -> c 227 | } 228 | else { 229 | write - 32 / 1.8 -> c 230 | } 231 | } 232 | } 233 | t = s with{.f := 212} 234 | `); 235 | expect(w.dumpAt('t.c')).toEqual(100); 236 | }); 237 | 238 | test('reverse conditional update', () => { 239 | let w = compile(` 240 | c: 0 241 | f =|> do{ 242 | try { 243 | check 1 =? 2 244 | c + 1 245 | } 246 | else { 247 | c + 2 248 | } 249 | }`); 250 | w.writeAt('f', 100); 251 | expect(w.dumpAt('c')).toEqual(98); 252 | }); 253 | 254 | test('reverse conditional update 2', () => { 255 | let w = compile(` 256 | c: 0 257 | f =|> do{ 258 | try { 259 | check 1 not=? 2 260 | c + 1 261 | } 262 | else { 263 | c + 2 264 | } 265 | }`); 266 | w.writeAt('f', 100); 267 | expect(w.dumpAt('c')).toEqual(99); 268 | }); 269 | 270 | 271 | test('update aggregation', () => { 272 | let w = compile(` 273 | s: record { 274 | c: 0 275 | d: 0 276 | } 277 | t =|> s`); 278 | w.writeAt('t.c', 100); 279 | expect(w.dumpAt('s')).toEqual({c: 100, d: 0}); 280 | }) 281 | 282 | test('update aggregation 2', () => { 283 | let w = compile(` 284 | s: record { 285 | c: 0 286 | d =|> c 287 | } 288 | t =|> s`); 289 | w.writeAt('t.d', 100); 290 | expect(w.dumpAt('s')).toEqual({c: 100, d: 100}); 291 | }) 292 | 293 | test('moot update', () => { 294 | let w = compile("c: 0, f =|> off on-update{write c <- + 1}"); 295 | w.turnOn('f'); 296 | expect(w.dumpAt('c')).toEqual(1); 297 | w.updateAt('f', '#off()'); 298 | expect(w.dumpAt('c')).toEqual(1); 299 | }); 300 | 301 | test('equal write', () => { 302 | expectCompiling(` 303 | c: 0 304 | d = record{x = c} 305 | f =|> off on-update{write d.x -> c} 306 | `).toThrow('writing same value'); 307 | }); 308 | 309 | test('try breaks provenance', () => { 310 | let w = compile(` 311 | c: 0 312 | d = try {check 1 =? 1; c} else {c} 313 | f =|> off on-update{write c <- d} 314 | `); 315 | w.turnOn('f'); 316 | expect(w.dumpAt('c')).toEqual(0); 317 | }); 318 | 319 | test('update order', () => { 320 | let w = compile(` 321 | c: 0 322 | f =|> record{x: 0, y: 0} on-update{ 323 | write c <- +(change.x) +(change.y) 324 | } 325 | g =|> off on-update{write 1 -> f.x; write 1 -> f.y} 326 | `); 327 | w.turnOn('g'); 328 | expect(w.dumpAt('c')).toEqual(2); 329 | }); 330 | 331 | test('input write conflict', () => { 332 | expectCompiling(` 333 | f: record{x: 0, y: 0} 334 | g =|> off on-update{write 1 -> f.x; write f <- with{.y := 1}} 335 | `).toThrow('write conflict') 336 | }); 337 | 338 | test('interface write conflict', () => { 339 | expectCompiling(` 340 | e: record{x: 0, y: 0} 341 | f =|> e 342 | g =|> off on-update{write 1 -> f.x; write f <- with{.y := 1}} 343 | `).toThrow('write conflict') 344 | }); 345 | 346 | test('overwrite', () => { 347 | expectCompiling(` 348 | c: 0 349 | g =|> off on-update{write 1 -> c; write 2 -> c} 350 | `).toThrow('write conflict'); 351 | }); 352 | 353 | test('forked overwrite', () => { 354 | expectCompiling(` 355 | c: 0 356 | f =|> off on-update{write c <- 1} 357 | g =|> off on-update{write c <- 2} 358 | h =|> off on-update{write f <- on, write g <- on} 359 | `).toThrow('write conflict'); 360 | }); 361 | 362 | test('reverse update', () => { 363 | let w = compile(` 364 | u = record { 365 | a: 0 366 | b = record{x: 0, y: 0} 367 | c =|> b with{.x := a} 368 | } 369 | v = u with{.c.x := 1} 370 | `); 371 | expect(w.dumpAt('v.a')).toEqual(1); 372 | expect(w.dumpAt('v.b')).toEqual({x: 0, y: 0}); 373 | }); 374 | 375 | test('reverse update 2', () => { 376 | let w = compile(` 377 | u = record { 378 | b: record{x: 0, y: 0} 379 | c =|> b with{.x := 1} 380 | } 381 | v = u with{.c.y := 1} 382 | `); 383 | expect(w.dumpAt('v.b')).toEqual({x: 0, y: 1}); 384 | }); 385 | 386 | test('such-that', () => { 387 | let w = compile(` 388 | a : tracked array{###} & 1 & 2 & 3 389 | b =|> a such-that{ check not=? 2} 390 | `); 391 | expect(w.dumpAt('b')).toEqual([1, 3]); 392 | w.writeAt('b.1', 10); 393 | expect(w.dumpAt('a')).toEqual([10, 2, 3]); 394 | expect(w.dumpAt('b')).toEqual([10, 3]); 395 | }) 396 | 397 | test('delete such-that', () => { 398 | let w = compile(` 399 | a : tracked array{###} & 1 & 2 & 3 400 | b =|> a such-that{ check not=? 2} 401 | `); 402 | w.deleteAt('b', 1) 403 | expect(w.dump()).toEqual({ a: [2, 3], b: [3] }); 404 | }) 405 | 406 | test('internal delete such-that', () => { 407 | let w = compile(` 408 | s = record{ 409 | a : tracked array{###} & 1 & 2 & 3 410 | b =|> a such-that{ check not=? 2} 411 | } 412 | t = s with{.b := delete!(1)} 413 | `); 414 | expect(w.dumpAt('t')).toEqual({a: [2, 3], b: [3]}); 415 | }) 416 | 417 | test('create such-that', () => { 418 | let w = compile(` 419 | a : tracked array{0} & 1 & 2 & 3 420 | b =|> a such-that{ check not=? 2} 421 | `); 422 | w.createAt('b'); 423 | expect(w.dump()).toEqual({a: [1, 2, 3, 0], b: [1, 3, 0]}); 424 | }) 425 | 426 | test('internal create such-that', () => { 427 | let w = compile(` 428 | s = record{ 429 | a : tracked array{###} & 1 & 2 & 3 430 | b =|> a such-that{ check not=? 2} 431 | } 432 | t = s with{.b := & 10} 433 | `); 434 | expect(w.dumpAt('t')).toEqual({a: [1, 2, 3, 10], b: [1, 3, 10]}); 435 | }) 436 | 437 | test('for-all', () => { 438 | let w = compile(` 439 | a : tracked array{###} & 1 & 2 & 3 440 | b =|> a for-all{+ 1} 441 | `); 442 | expect(w.dumpAt('b')).toEqual([2, 3, 4]); 443 | w.writeAt('b.2', 11); 444 | expect(w.dumpAt('a')).toEqual([1, 10, 3]); 445 | expect(w.dumpAt('b')).toEqual([2, 11, 4]); 446 | }) 447 | 448 | test('for-all with update', () => { 449 | let w = compile(` 450 | r: record { 451 | a : tracked array{###} & 1 & 2 & 3 452 | b =|> a for-all{+ 1} 453 | } 454 | s = r with{.b := update!(2, .value := 11) } 455 | `); 456 | expect(w.dumpAt('s')).toEqual({a: [1, 10, 3], b: [2, 11, 4]}); 457 | }) 458 | 459 | test('for-all with delete', () => { 460 | let w = compile(` 461 | r: record { 462 | a : tracked array{###} & 1 & 2 & 3 463 | b =|> a for-all{+ 1} 464 | } 465 | s = r with{.b := delete! 2 } 466 | `); 467 | expect(w.dumpAt('s')).toEqual({a: [1, 3], b: [2, 4]}); 468 | }) 469 | 470 | test('for-all with create', () => { 471 | let w = compile(` 472 | r: record { 473 | a : tracked array{###} & 1 & 2 & 3 474 | b =|> a for-all{+ 1} 475 | } 476 | s = r with{.b := & 11 } 477 | `); 478 | expect(w.dumpAt('s')).toEqual({a: [1, 2, 3, 10], b: [2, 3, 4, 11]}); 479 | }) 480 | 481 | test('for-all with noop create', () => { 482 | let w = compile(` 483 | r: record { 484 | a : tracked array{###} & 1 & 2 & 3 485 | b =|> a for-all{+ 1} 486 | } 487 | s = r with{.b := & 1 } 488 | `); 489 | expect(w.dumpAt('s')).toEqual({a: [1, 2, 3, 0], b: [2, 3, 4, 1]}); 490 | }) 491 | 492 | test('for-all with empty on-update', () => { 493 | let w = compile(` 494 | a : tracked array{###} & 1 & 2 & 3 495 | b =|> a for-all{on-update{1}} 496 | `); 497 | expect(w.dumpAt('b')).toEqual([1, 2, 3]); 498 | w.writeAt('b.2', 10); 499 | expect(w.dumpAt('a')).toEqual([1, 2, 3]); 500 | }) 501 | 502 | test('for-all with on-update', () => { 503 | let w = compile(` 504 | a : tracked array{###} & 1 & 2 & 3 505 | b =|> a for-all{item:[]; on-update{write 1 -> item}} 506 | `); 507 | expect(w.dumpAt('b')).toEqual([1, 2, 3]); 508 | w.writeAt('b.2', 10); 509 | expect(w.dumpAt('a')).toEqual([1, 1, 3]); 510 | }) 511 | 512 | test('for-all write encapsulation', () => { 513 | expectCompiling(` 514 | c : 0 515 | a : tracked array{###} & 1 & 2 & 3 516 | b =|> a for-all{item: []; on-update{write 1 -> item; write 1 -> c}} 517 | `).toThrow('external write from for-all') 518 | }) 519 | 520 | test('for-all reference encapsulation', () => { 521 | expectCompiling(` 522 | c : 0 523 | a : tracked array{###} & 1 & 2 & 3 524 | b =|> a for-all{c} 525 | `).toThrow('external write from for-all') 526 | }) 527 | 528 | test('updatable query', () => { 529 | let w = compile(` 530 | customers: do{ 531 | tracked table{customer-id: ###} 532 | &{.customer-id:= 1} 533 | &{.customer-id:= 2} 534 | } 535 | orders: do{ 536 | tracked table{order-id: ###, customer-id: ###} 537 | &{.order-id:= 1, .customer-id:= 1} 538 | &{.order-id:= 2, .customer-id:= 1} 539 | } 540 | 541 | query =|> customers for-all{ 542 | extend{their-orders =|> orders such-that{.customer-id =? customer-id}} 543 | } 544 | `) 545 | 546 | expect(w.dumpAt('query')).toEqual([ 547 | { 548 | 'customer-id': 1, 549 | 'their-orders': [ 550 | { 'customer-id': 1, 'order-id': 1 }, 551 | { 'customer-id': 1, 'order-id': 2 }, 552 | ] 553 | }, 554 | { 555 | 'customer-id': 2, 556 | 'their-orders':[], 557 | }, 558 | ]); 559 | 560 | // update nested table 561 | w.writeAt('query.1.their-orders.2.customer-id', 2); 562 | expect(w.dumpAt('orders')).toEqual([ 563 | { 'customer-id': 1, 'order-id': 1 }, 564 | { 'customer-id': 2, 'order-id': 2 }, 565 | ]); 566 | expect(w.dumpAt('query')).toEqual([ 567 | { 568 | 'customer-id': 1, 569 | 'their-orders': [ 570 | { 'customer-id': 1, 'order-id': 1 }, 571 | ] 572 | }, 573 | { 574 | 'customer-id': 2, 575 | 'their-orders': [ 576 | { 'customer-id': 2, 'order-id': 2 }, 577 | ], 578 | }, 579 | ]); 580 | 581 | // delete from nested table 582 | w.deleteAt('query.2.their-orders', 1); 583 | expect(w.dumpAt('orders')).toEqual([ 584 | { 'customer-id': 1, 'order-id': 1 }, 585 | ]); 586 | 587 | // update containing table 588 | w.writeAt('query.2.customer-id', 3); 589 | expect(w.dumpAt('customers')).toEqual([ 590 | { 'customer-id': 1 }, 591 | { 'customer-id': 3 }, 592 | ]); 593 | 594 | // delete from containing table 595 | w.deleteAt('query', 2); 596 | expect(w.dumpAt('customers')).toEqual([ 597 | { 'customer-id': 1 }, 598 | ]); 599 | 600 | // create in containing table 601 | w.createAt('query'); 602 | w.writeAt('query.2.customer-id', 3); 603 | expect(w.dumpAt('customers')).toEqual([ 604 | { 'customer-id': 1 }, 605 | { 'customer-id': 3 }, 606 | ]); 607 | 608 | // create in nested table 609 | w.createAt('query.1.their-orders'); 610 | w.writeAt(`orders.2.order-id`, 3); 611 | w.writeAt(`orders.2.customer-id`, 1); 612 | expect(w.dumpAt('query')).toEqual([ 613 | { 614 | 'customer-id': 1, 615 | 'their-orders': [ 616 | { 'customer-id': 1, 'order-id': 1 }, 617 | { 'customer-id': 1, 'order-id': 3 }, 618 | ] 619 | }, 620 | { 621 | 'customer-id': 3, 622 | 'their-orders': [], 623 | }, 624 | ]); 625 | }) 626 | test('query update context error', () => { 627 | expectCompiling(` 628 | customers: tracked table{customer-id: ###} 629 | orders: tracked table{order-id: ###, customer-id: ###} 630 | query =|> customers for-all{ 631 | extend{their-orders =|> orders such-that{.customer-id =? customer-id}} 632 | } 633 | x = query &{.their-orders := &()} 634 | `) 635 | .toThrow('write outside context') 636 | }) 637 | 638 | test('register', () => { 639 | let w = compile(` 640 | a: 'foo' 641 | v = with{ 642 | item: that 643 | show-state: register yes 644 | record{ 645 | value = try { 646 | check show-state.yes? 647 | item 648 | } else { 649 | '' 650 | } 651 | show =|> off on-update {write show-state <- flip()} 652 | } 653 | } 654 | `); 655 | expect(w.dump()).toEqual({ a: 'foo', v: { value: 'foo', show: off } }); 656 | w.turnOn('v.show'); 657 | expect(w.dump()).toEqual({ a: 'foo', v: { value: '', show: off } }); 658 | }) 659 | 660 | test('register iteration', () => { 661 | let w = compile(` 662 | a: tracked array{''} & 'foo' & 'bar' & 'baz' 663 | v = a for-all { 664 | item:[] 665 | show-state: register yes 666 | record{ 667 | value = try { 668 | check show-state.yes? 669 | item 670 | } else { 671 | '' 672 | } 673 | show =|> off on-update {write show-state <- flip()} 674 | } 675 | } 676 | `); 677 | expect(w.dump()).toEqual({ 678 | a: ['foo', 'bar', 'baz'], 679 | v: [ 680 | { value: 'foo', show: off }, 681 | { value: 'bar', show: off }, 682 | { value: 'baz', show: off }, 683 | ] 684 | }); 685 | w.turnOn('v.2.show'); 686 | expect(w.dump()).toEqual({ 687 | a: ['foo', 'bar', 'baz'], 688 | v: [ 689 | { value: 'foo', show: off }, 690 | { value: '', show: off }, 691 | { value: 'baz', show: off }, 692 | ] 693 | }); 694 | w.deleteAt('a', 1); 695 | expect(w.dump()).toEqual({ 696 | a: ['bar', 'baz'], 697 | v: [ 698 | { value: '', show: off }, 699 | { value: 'baz', show: off }, 700 | ] 701 | }); 702 | }); 703 | 704 | test('input reference hygiene', () => { 705 | expectCompiling(` 706 | a: tracked array{''} & 'foo' & 'bar' & 'baz' 707 | v: a for-all { 708 | item:[] 709 | show-state: register on 710 | record{ 711 | value = try { 712 | check show-state =? on 713 | item 714 | } else { 715 | '' 716 | } 717 | show =|> off on-update {write show-state <- flip()} 718 | } 719 | } 720 | `).toThrow('input field value referencing its own formula'); 721 | }); 722 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "module": "commonjs", 5 | "outDir": "./dist", 6 | "strict": true, 7 | "noFallthroughCasesInSwitch": true, 8 | "noImplicitReturns": true, 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "removeComments": false, 12 | "preserveConstEnums": true, 13 | "sourceMap": true, 14 | "declaration": true, 15 | "stripInternal": true, 16 | "jsx": "react", 17 | "pretty": true, 18 | "esModuleInterop": true 19 | }, 20 | "include": [ 21 | "./src/*", 22 | "./test/*" 23 | ] 24 | } --------------------------------------------------------------------------------