septima-lang 0.0.21 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +435 -0
- package/change-log.md +57 -0
- package/dist/src/ast-node.d.ts +13 -0
- package/dist/src/ast-node.js +18 -1
- package/dist/src/parser.d.ts +1 -0
- package/dist/src/parser.js +75 -5
- package/dist/src/runtime.js +20 -1
- package/dist/tests/parser.spec.d.ts +1 -0
- package/dist/tests/parser.spec.js +75 -0
- package/dist/tests/septima-compile.spec.d.ts +1 -0
- package/dist/tests/septima-compile.spec.js +118 -0
- package/dist/tests/septima.spec.d.ts +1 -0
- package/dist/tests/septima.spec.js +1090 -0
- package/dist/tests/value.spec.d.ts +1 -0
- package/dist/tests/value.spec.js +263 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +3 -3
- package/src/a.js +66 -0
- package/src/ast-node.ts +340 -0
- package/src/extract-message.ts +5 -0
- package/src/fail-me.ts +7 -0
- package/src/find-array-method.ts +124 -0
- package/src/find-string-method.ts +84 -0
- package/src/index.ts +1 -0
- package/src/location.ts +13 -0
- package/src/parser.ts +698 -0
- package/src/result.ts +54 -0
- package/src/runtime.ts +462 -0
- package/src/scanner.ts +136 -0
- package/src/septima.ts +218 -0
- package/src/should-never-happen.ts +4 -0
- package/src/source-code.ts +101 -0
- package/src/stack.ts +18 -0
- package/src/switch-on.ts +4 -0
- package/src/symbol-table.ts +9 -0
- package/src/value.ts +823 -0
- package/tests/parser.spec.ts +81 -0
- package/tests/septima-compile.spec.ts +187 -0
- package/tests/septima.spec.ts +1169 -0
- package/tests/value.spec.ts +291 -0
package/src/septima.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import * as path from 'path'
|
|
2
|
+
|
|
3
|
+
import { Unit, UnitId } from './ast-node'
|
|
4
|
+
import { failMe } from './fail-me'
|
|
5
|
+
import { Parser } from './parser'
|
|
6
|
+
import { formatTrace, Result, ResultSink } from './result'
|
|
7
|
+
import { Outputter, Runtime, Verbosity } from './runtime'
|
|
8
|
+
import { Scanner } from './scanner'
|
|
9
|
+
import { shouldNeverHappen } from './should-never-happen'
|
|
10
|
+
import { SourceCode } from './source-code'
|
|
11
|
+
|
|
12
|
+
interface Options {
|
|
13
|
+
/**
|
|
14
|
+
* A callback function to be invoked when the Septima program evaluated to `sink`. Allows the caller to determine
|
|
15
|
+
* which value will be returned in that case. For instance, passing `() => undefined` will translate a `sink` value
|
|
16
|
+
* to `undefined`. The default behavior is to throw an error.
|
|
17
|
+
*/
|
|
18
|
+
onSink?: (res: ResultSink) => unknown
|
|
19
|
+
/**
|
|
20
|
+
* A custom output function that will receive the values that are passed to console.log() calls in the septima code.
|
|
21
|
+
*/
|
|
22
|
+
consoleLog?: Outputter
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Executable {
|
|
26
|
+
execute(args: Record<string, unknown>): Result
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type SyncCodeReader = (resolvePath: string) => string | undefined
|
|
30
|
+
type CodeReader = (resolvePath: string) => Promise<string | undefined>
|
|
31
|
+
|
|
32
|
+
export interface SourceUnit {
|
|
33
|
+
sourceCode: SourceCode
|
|
34
|
+
unit: Unit
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class Septima {
|
|
38
|
+
/**
|
|
39
|
+
* Runs a Septima program and returns the value it evaluates to. If it evaluates to `sink`, returns the value computed
|
|
40
|
+
* by `options.onSink()` - if present, or throws an error - otherwise.
|
|
41
|
+
*
|
|
42
|
+
* This method is the simplest way to evaluate a Septima program, and it fits many common use cases. One can also use
|
|
43
|
+
* `.compute()` to get additional details about the execution.
|
|
44
|
+
*
|
|
45
|
+
* @param input the source code of the Septima program
|
|
46
|
+
* @param options
|
|
47
|
+
* @returns the value that `input` evaluates to
|
|
48
|
+
*/
|
|
49
|
+
static run(input: string, options?: Options, args: Record<string, unknown> = {}): unknown {
|
|
50
|
+
const onSink =
|
|
51
|
+
options?.onSink ??
|
|
52
|
+
((r: ResultSink) => {
|
|
53
|
+
throw new Error(r.message)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const fileName = '<inline>'
|
|
57
|
+
const contentRec: Record<string, string> = { [fileName]: input }
|
|
58
|
+
const readFile = (m: string) => contentRec[m]
|
|
59
|
+
const res = new Septima(undefined, options?.consoleLog).compileSync(fileName, readFile).execute(args)
|
|
60
|
+
if (res.tag === 'ok') {
|
|
61
|
+
return res.value
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (res.tag === 'sink') {
|
|
65
|
+
return onSink(res)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
shouldNeverHappen(res)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private readonly unitByUnitId = new Map<UnitId, SourceUnit>()
|
|
72
|
+
|
|
73
|
+
constructor(private readonly sourceRoot = '', private readonly consoleLog?: Outputter) {}
|
|
74
|
+
|
|
75
|
+
compileSync(fileName: string, readFile: SyncCodeReader) {
|
|
76
|
+
fileName = this.relativize(fileName)
|
|
77
|
+
const acc = [fileName]
|
|
78
|
+
this.pumpSync(acc, readFile)
|
|
79
|
+
return this.getExecutableFor(fileName)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async compile(fileName: string, readFile: CodeReader) {
|
|
83
|
+
fileName = this.relativize(fileName)
|
|
84
|
+
const acc = [fileName]
|
|
85
|
+
await this.pump(acc, readFile)
|
|
86
|
+
return this.getExecutableFor(fileName)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private relativize(fileName: string) {
|
|
90
|
+
if (path.isAbsolute(fileName)) {
|
|
91
|
+
return path.relative(this.sourceRoot, fileName)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return fileName
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private getExecutableFor(fileName: string): Executable {
|
|
98
|
+
// Verify that a unit for the main file exists
|
|
99
|
+
this.unitOf(undefined, fileName)
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
execute: (args: Record<string, unknown>) => {
|
|
103
|
+
const value = this.execute(fileName, 'quiet', args)
|
|
104
|
+
const ret: Result = { value: value.export(), tag: 'ok' }
|
|
105
|
+
return ret
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private execute(fileName: string, verbosity: Verbosity, args: Record<string, unknown>) {
|
|
111
|
+
const runtime = new Runtime(
|
|
112
|
+
this.unitOf(undefined, fileName),
|
|
113
|
+
verbosity,
|
|
114
|
+
(a, b) => this.unitOf(a, b),
|
|
115
|
+
args,
|
|
116
|
+
this.consoleLog,
|
|
117
|
+
)
|
|
118
|
+
const c = runtime.compute()
|
|
119
|
+
|
|
120
|
+
if (c.value) {
|
|
121
|
+
return c.value
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const formatted = formatTrace(c.expressionTrace, this.unitByUnitId)
|
|
125
|
+
throw new Error(`${c.errorMessage} when evaluating:\n${formatted}`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Translates the filename into a resolved path (to be read) if it has not been read yet. If it has been read,
|
|
130
|
+
* returns undefined
|
|
131
|
+
*/
|
|
132
|
+
private computeResolvedPathToRead(fileName: string) {
|
|
133
|
+
const pathFromSourceRoot = this.getPathFromSourceRoot(undefined, fileName)
|
|
134
|
+
if (this.unitByUnitId.has(pathFromSourceRoot)) {
|
|
135
|
+
return undefined
|
|
136
|
+
}
|
|
137
|
+
return path.join(this.sourceRoot, pathFromSourceRoot)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private loadFileContent(resolvedPath: string | undefined, content: string | undefined, acc: string[]) {
|
|
141
|
+
if (resolvedPath === undefined) {
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
const pathFromSourceRoot = path.relative(this.sourceRoot, resolvedPath)
|
|
145
|
+
if (content === undefined) {
|
|
146
|
+
throw new Error(`Cannot find file '${path.join(this.sourceRoot, pathFromSourceRoot)}'`)
|
|
147
|
+
}
|
|
148
|
+
const sourceCode = new SourceCode(content, pathFromSourceRoot)
|
|
149
|
+
const scanner = new Scanner(sourceCode)
|
|
150
|
+
const parser = new Parser(scanner)
|
|
151
|
+
const unit = parser.parse()
|
|
152
|
+
|
|
153
|
+
this.unitByUnitId.set(pathFromSourceRoot, { unit, sourceCode })
|
|
154
|
+
acc.push(...unit.imports.map(at => this.getPathFromSourceRoot(pathFromSourceRoot, at.pathToImportFrom.text)))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private unitOf(importerPathFromSourceRoot: string | undefined, relativePath: string) {
|
|
158
|
+
const p = this.getPathFromSourceRoot(importerPathFromSourceRoot, relativePath)
|
|
159
|
+
const { unit } =
|
|
160
|
+
this.unitByUnitId.get(p) ?? failMe(`Encluntered a file which has not been loaded (file name: ${p})`)
|
|
161
|
+
return unit
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private getPathFromSourceRoot(startingPoint: string | undefined, relativePath: string) {
|
|
165
|
+
if (path.isAbsolute(relativePath)) {
|
|
166
|
+
throw new Error(`An absolute path is not allowed in import (got: ${relativePath})`)
|
|
167
|
+
}
|
|
168
|
+
const joined = startingPoint === undefined ? relativePath : path.join(path.dirname(startingPoint), relativePath)
|
|
169
|
+
const ret = path.normalize(joined)
|
|
170
|
+
if (ret.startsWith('.')) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`resolved path (${path.join(this.sourceRoot, ret)}) is pointing outside of source root (${this.sourceRoot})`,
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
return ret
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// loadSync() need to be kept in sync with load(), ditto pumpSync() w/ pump(). There is deep testing coverage for the
|
|
179
|
+
// sync variant but only partial coverage for the async variant.
|
|
180
|
+
// An extra effort was taken in order to reduce the duplication between the two variants. This attempt achieved its
|
|
181
|
+
// goal (to some extent) but resulted in a somewhat unnatural code (like using an "acc" parameter to collect items,
|
|
182
|
+
// instead of returning an array, having an external "pump" logic instead of a plain recursion). Perhaps a better
|
|
183
|
+
// approach is to have a contract test and run it twice thus equally testing the two variants.
|
|
184
|
+
|
|
185
|
+
private loadSync(fileName: string, readFile: SyncCodeReader, acc: string[]) {
|
|
186
|
+
const p = this.computeResolvedPathToRead(fileName)
|
|
187
|
+
const content = p && readFile(p)
|
|
188
|
+
this.loadFileContent(p, content, acc)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private async load(fileName: string, readFile: CodeReader, acc: string[]): Promise<void> {
|
|
192
|
+
const p = this.computeResolvedPathToRead(fileName)
|
|
193
|
+
const content = p && (await readFile(p))
|
|
194
|
+
this.loadFileContent(p, content, acc)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private pumpSync(acc: string[], readFile: SyncCodeReader) {
|
|
198
|
+
while (true) {
|
|
199
|
+
const curr = acc.pop()
|
|
200
|
+
if (!curr) {
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
this.loadSync(curr, readFile, acc)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private async pump(acc: string[], readFile: CodeReader) {
|
|
209
|
+
while (true) {
|
|
210
|
+
const curr = acc.pop()
|
|
211
|
+
if (!curr) {
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
await this.load(curr, readFile, acc)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { AstNode, span } from './ast-node'
|
|
2
|
+
import { Location, Location2d, Span } from './location'
|
|
3
|
+
|
|
4
|
+
export class SourceCode {
|
|
5
|
+
constructor(readonly input: string, readonly pathFromSourceRoot: string) {}
|
|
6
|
+
|
|
7
|
+
formatTrace(trace: AstNode[]): string {
|
|
8
|
+
const spacer = ' '
|
|
9
|
+
|
|
10
|
+
const formatted = trace
|
|
11
|
+
.map(ast => this.sourceRef(span(ast)))
|
|
12
|
+
.reverse()
|
|
13
|
+
.join(`\n${spacer}`)
|
|
14
|
+
return `${spacer}${formatted}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
formatAst(ast: AstNode): string {
|
|
18
|
+
return this.sourceRef(span(ast))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
sourceRef(span: Span | undefined) {
|
|
22
|
+
if (!span) {
|
|
23
|
+
return `at <unknown location>`
|
|
24
|
+
}
|
|
25
|
+
return `at (${this.pathFromSourceRoot}:${this.formatSpan(span)}) ${this.interestingPart(span)}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private formatSpan(span: Span) {
|
|
29
|
+
const f = this.resolveLocation(span.from)
|
|
30
|
+
const t = this.resolveLocation(span.to)
|
|
31
|
+
if (f.line === t.line) {
|
|
32
|
+
return `${f.line + 1}:${f.col + 1}..${t.col + 1}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return `${f.line + 1}:${f.col + 1}..${t.line + 1}:${t.col + 1}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private interestingPart(span: Span) {
|
|
39
|
+
const f = this.resolveLocation(span.from)
|
|
40
|
+
const t = this.resolveLocation(span.to)
|
|
41
|
+
|
|
42
|
+
const strip = (s: string) => s.replace(/^[\n]*/, '').replace(/[\n]*$/, '')
|
|
43
|
+
const limit = 80
|
|
44
|
+
|
|
45
|
+
const lineAtFrom = this.lineAt(span.from)
|
|
46
|
+
if (f.line !== t.line) {
|
|
47
|
+
return `${strip(lineAtFrom).substring(0, limit)}...`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const stripped = strip(lineAtFrom.substring(f.col, t.col + 1))
|
|
51
|
+
if (stripped.length <= limit) {
|
|
52
|
+
return stripped
|
|
53
|
+
}
|
|
54
|
+
return `${stripped.substring(0, limit)}...`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private resolveLocation(loc: Location): Location2d {
|
|
58
|
+
const prefix = this.input.slice(0, loc.offset)
|
|
59
|
+
let line = 0
|
|
60
|
+
for (let i = 0; i < prefix.length; ++i) {
|
|
61
|
+
const ch = prefix[i]
|
|
62
|
+
if (ch === '\n') {
|
|
63
|
+
line += 1
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let col = 0
|
|
68
|
+
for (let i = prefix.length - 1; i >= 0; --i, ++col) {
|
|
69
|
+
const ch = prefix[i]
|
|
70
|
+
if (ch === '\n') {
|
|
71
|
+
break
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { line, col }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Returns a span starting a the given input location that runs all the way to the end of the line. Specifically, the
|
|
80
|
+
* returned span's `.to` location will point at the character (in the same line as `loc`) that is immediately before
|
|
81
|
+
* the terminating newline character.
|
|
82
|
+
*/
|
|
83
|
+
expandToEndOfLine(loc: Location): Span {
|
|
84
|
+
let endOfLine = this.input.indexOf('\n', loc.offset)
|
|
85
|
+
if (endOfLine < 0) {
|
|
86
|
+
endOfLine = this.input.length - 1
|
|
87
|
+
}
|
|
88
|
+
return { from: loc, to: { offset: endOfLine } }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private lineAt(loc: Location) {
|
|
92
|
+
const precedingNewline = this.input.lastIndexOf('\n', loc.offset)
|
|
93
|
+
// add a + 1 to skip over the '\n' character (it is not part of the line). Also works if precedingNewLine is -1 (no
|
|
94
|
+
// preceding newline exists)
|
|
95
|
+
const startOfLine = precedingNewline + 1
|
|
96
|
+
const endOfLine = this.expandToEndOfLine(loc).to
|
|
97
|
+
|
|
98
|
+
const ret = this.input.substring(startOfLine, endOfLine.offset + 1)
|
|
99
|
+
return ret
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/stack.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { AstNode } from './ast-node'
|
|
2
|
+
export type T = { ast: AstNode; next: T } | undefined
|
|
3
|
+
|
|
4
|
+
export function push(ast: AstNode, s: T) {
|
|
5
|
+
return { ast, next: s }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function pop(s: T) {
|
|
9
|
+
if (typeof s === 'undefined') {
|
|
10
|
+
throw new Error(`Cannot pop from an empty stack`)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return s.next
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function empty(): T {
|
|
17
|
+
return undefined
|
|
18
|
+
}
|
package/src/switch-on.ts
ADDED