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/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,4 @@
1
+ export function shouldNeverHappen(n: never): never {
2
+ // This following line never gets executed. It is here just to make the compiler happy.
3
+ throw new Error(`This should never happen ${n}`)
4
+ }
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ export function switchOn<G, K extends string>(selector: K, cases: Record<K, () => G>): G {
2
+ const f = cases[selector]
3
+ return f()
4
+ }
@@ -0,0 +1,9 @@
1
+ import { Value } from './value'
2
+
3
+ export type Visibility = 'EXPORTED' | 'INTERNAL'
4
+
5
+ export interface SymbolTable {
6
+ lookup(sym: string): Value
7
+ export(): Record<string, unknown>
8
+ exportValue(): Record<string, Value>
9
+ }