UNPKG

sai-language

Version:

An object-oriented language designed to afford code comprehension and maintenance. Transpiles in-place to Javascript.

1,024 lines (707 loc) 33.6 kB
# SAI Language Main framework. MIT License. Created by Sean M Puckett <sean.m.puckett@gmail.com> ## Constants The `reference` section holds global constants. reference: FS require('fs') PATH require('path') SAILIB require('sai-library') DIRNAME ~__dirname INDENT '%%INDENT%%' DEDENT '%%DEDENT%%' ### Default configuration structure SAI's configuration defaults are defined here. It's best to not change these. Use the `Configure` method in this object to effect changes. DEFAULT_config: _Globals:_ You can add your own case-sensitive globals by adding them to the _globals_ array. Verbs should be text that compiles to a recognizable JS value. We can't directly import `process` because that's a SAI keyword, so both it and `console` get a prefix to identify them as external objects. globals: 'debug' '$AI.debug_op' 'require' '__require' 'assert' '$AI.assert' 'environment' '$AI.environment' 'jsprocess' 'process' 'jsconsole' 'console' _Imports_: bring some Javascript and node globals into the SAI namespace. This list is integrated into the above array as a verbatim translation. imports list parseFloat, parseInt, decodeURI, decodeURIComponent, encodeURI, encodeURIComponent Object, Function, Boolean, Symbol Error, EvalError, InternalError, RangeError, ReferenceError, SyntaxError, TypeError, URIError Math, Number, Date, String, RegExp, Intl Array, Int8Array, Uint8Array, Uint8ClampedArray, Int16Array, Uint16Array, Int32Array, Uint32Array Float32Array, Float64Array, Map, Set, WeakMap, WeakSet ArrayBuffer, DataView, JSON _Paths_: The list of paths is searched when calling SAI.Require when using the default loader. paths: DIRNAME + '/' _Options_: __speedometer__ displays compile time for each module. __beautify__ runs generated javascript through a beautifier. Recommended because the native source is ugly. __linenos__ includes original `.sai` line numbers as comments in the generated `.js` file. options: speedometer false beautify true linenos false _Loader_: The function called when `create` is invoked. Hook or redefine this in order to alter how SAI processes requests for source. Initialized later in the `Clean` method. Loader undefined ### Built-in Constructs These are the names of SAI objects that will be loaded to populate the `pipers` and `constructs` metadata. CONSTRUCTS list StateC, PromisingC, ReturnC, IterateC, TryCatchC, IfElseC, SwitchC, MiscC ListsP, QueryP, CrudP Keywords ### Construct Syntax The *keys* in the following object definitions are the valid parse codes for the different types of grammar. The *value* associated with each key is a human-readable string that describes what the parse code is looking for. This text will form part of the automatic documentation generator. VALIDSYNTAX: constructs: 'block' '[indent code block]' 'exprout' '[expr] (AS var1, var2...)' 'exproutblock' '[expr] (AS var1, var2...) [indent code block]' 'exprs' '[expr1 (, expr2, ...)]' 'exprsblock' '[expr1 (, expr2, ...)] [indent code block]' 'exprsoutblock' '[expr1 (, expr2, ...)] (AS var1, var2, ...) [indent code block]' 'exprsusing' '[expr1 (, expr2, ...)] USING [function reference]' 'exprusing' '[expr] USING [function reference]' 'none' ' ' 'outblock' '(AS var1, var2...) [indent code block]' 'rvalue' '[variable]' 'verb' '[verb sentence]' pipers: 'expr' '[expr]' 'exprs' '[expr1 (, expr2, ...)]' 'none' ' ' 'outblock' '(AS var1, var2...) [indent code block]' 'use' 'USING [function reference]' 'valexpr' '[value] [expr]' 'valuse' '[value] USING [function reference]' 'valoutblock' '[value] (AS var1, var2...) [indent code block]' These are additional grammatical types that need to be defined here for the documentation system. They don't affect the parser. globals blank keywords blank operators blank pronouns blank syntax blank ## SAI Object Defined as a singleton, this object will only produce one instance of itself. object SAI singleton 0.2.8 ### Instance Variables instance: prototypes blank sources blank protogens blank isas blank config blank persist blank secondclass blank mainParser undefined These are placeholder values for dynamically generated instance *methods* (thus the capitalization) -- they're declared here so the compiler knows they're part of the object and doesn't complain about using them undeclared. ProtoConstructor undefined Parse undefined Beautify undefined ### Instantiate method Called once when the SAI object is created. Instantiate task set Parse GetParser() ProtoConstructor task set @@Constructor task nop @@__tobelocked empty @@__tobefrozen empty @@__contracts empty @@__unverified true @@isof blank return @@ Because we are the compiler, we need to modify the runtime so that we can do dynamic compilation, etc. The following hooks take over key functions in the runtime library and configuration system with ones more suitable for dynamic mode. SAILIB.import bind Require SAILIB.create_op bind Create SAILIB.environment bind Environment IntegrateBasicConstructs Clean ### Clean method Resets the compiler, empties all caches, clears configuration data, reloads construct metadadata, etc. Clean task set prototypes blank sources blank protogens blank isas blank config copy DEFAULT_config persist: globalcount 1 config.Loader bind GetSourceFromPaths Copy the verbatim JS global imports into the parser's global lookup a.k.a. _globals_ so it knows about them. ply config.imports set config.globals\it it End of `Clean` method definition. ### IntegrateConstruct method IntegrateConstruct task given manifest This list allows us to validate the metadata provided for the constructs. Each construct definition of the given type must include at least these entries. set requiredElements to: constructs: 'clauses' 'clauses list' 'repeats' 'repeats flag' 'handler' 'handler function' pipers: 'clauses' 'clauses list' 'handler' 'handler function' Worker function to throw an error when something goes wrong. set Fail to task given msg throw new Error 'SAI Construct Manager: manifest for ${category}/${constructName}${msg}' Iterate over the categories (pipers, constructs, etc) in the manifest. each manifest as library, category set secondclass\category default blank unless VALIDSYNTAX\category Fail ' unsupported category type' Validate each item in the category against expectations. each library as construct, constructName if secondclass\category\constructName Fail ' overwriting previously declared handler' each requiredElements\category as desc, elem unless exists construct\elem Fail ' is missing ${desc}.' if exists construct.clauses and not exists construct.clauses\constructName Fail ' is missing a clause definition for itself' if ?requiredElements\category.clauses each construct.clauses as syntax, clauseName each syntax unless VALIDSYNTAX\category\key Fail '/${clauseName} asks for illegal syntax "${key}"' else Fail ' has no clauses defined' No faults detected, add the constructs to the master list later given to the parser. set secondclass\category\constructName copy construct Done. ### IntegrateBasicConstructs method Iterate over the base set of construct names, require them, and add them to the manifest. IntegrateBasicConstructs task ply CONSTRUCTS as file try set fn to "./"+file set implementation require(fn) set implementation.SAI @ // this is for introspection in Keywords.sai set manifest to implementation.manifest IntegrateConstruct manifest catch set error.message to 'In Construct file ${file} ---\n '+self debug error ### Document method Using a similar technique to IntegrateConstructs, build an encyclopedia of documentation. Document task Variable `documents` holds the final manpages. set documents blank The `prefix` helps us format the syntax reference for pipers. set prefix: 'pipers': main '... | ' aux '(optional) ' The variable `seealso` is a list of cross-references. set seealso blank Iterate through the constructs, loading them like IntegrateConstructs. ply CONSTRUCTS as file set fn to "./"+file implementation require(fn) implementation.SAI @ // this is for introspection in Keywords.sai manifest to implementation.manifest docs to implementation.docs each manifest as library, category set documents\category default blank each library as construct, constructName set doc to docs\category\constructName or blank set synref empty each construct.clauses as requestedSyntax, clauseName set seealso\clauseName default empty push! seealso\clauseName array (doc.category ? category), constructName, doc.title ? clauseName each requestedSyntax as val, type set sub to clauseName is constructName ?? 'main' :: 'aux' push! synref '${?prefix\category\sub ? ''}${clauseName} ${VALIDSYNTAX\category\type}' push! synref '' set page: name constructName subtitle 'subtitle' summary 'summary' synref join! synref '\n' detail 'detail' set documents\category\constructName page | update doc return: documents documents references seealso ### Dedenter method With SAI whitespace is relevant, but it turns out that's pretty hard to handle in a parse grammar. This function transforms levels of indent into lexical markers `INDENT` and `DEDENT` that the parser can deal with simply. It also strips out comments, handles here documents, and detects *literal* source and makes adjustments accordingly. Dedenter task given src set heredocstate 0 heredepth 0 indent: 0 literate false offset 1 lines empty Split incoming source into lines. rawlines src.split(/\r\n|[\r\n\u0085\u2028\u2029]/) Utility function to return a code excerpt. Context task given lineno set result 'near line ${lineno}:\n' count lineno-3, lineno+4 exists rawlines\counter set result + '${counter}:${counter is lineno ?? '->' :: ' '} ${it}\n' return result If the first line is a unix-style 'shebang', remove it. if '#!' is (rawlines.0 | limit 0, 2) shift! rawlines inc offset If there is a hashmark at the beginning of the first ten lines, assume the source is is *literate* format -- that it's a Markdown document where everything is a comment except code blocks, except code blocks that have the first line ending in `// example`. count 10 ?< rawlines.length as i if match! rawlines\i /^\#+\s/ set literate true Iterate over each line. ply rawlines as line, lineno Tab characters are not permitted. if line.indexOf('\t') >= 0 throw new Error "SAI Compiler: tab characters are not supported in source files ${!Context lineno}" Trim off trailing spaces. set trail line.length while ' ' is line[trail-1] .. dec trail set line (self | limit 0, trail) Count leading spaces and save the result as `trimmed`. set depth 0 while ' ' is line\depth .. inc depth set trimmed (line | limit depth, undefined) Flags. set isComment '//' is (trimmed | limit 0, 2) set isBlank 0 is trimmed.length If we're in literate mode, turn everything that isn't indented four spaces into a comment -- that is, ignore it. if literate if depth<2 unless isBlank set isExample false unless isBlank and heredocstate is 2 continue elsif depth <4 throw new Error ''' SAI Compiler: file looks 'literate' with a # header up top, but has lines with an indent of 2 or 3 spaces. Literate SAI files should have left justified markdown (one space indent allowed for lists) and all code should be indented four spaces or more. So, basically, this looks wrong: ${!Context lineno} elsif isExample continue elsif '// example' is (line | limit -10) set isExample true continue else set depth to self - 4 set line to self | limit 4, undefined If we're still here, and we're examining an actual line of code... unless isComment or isBlank Handle _here documents_, as well as noting changes in indent depth with the `INDENT` and `DEDENT` markers. set heredoc "'''" is (line | limit -3) if heredocstate is 2 if depth < heredepth set heredocstate 0 if heredocstate is 1 if depth <= heredepth throw new Error "SAI: multi-line quotes (a.k.a. here documents) must be indented ${!Context lineno}" push! lines INDENT unshift! indent depth set heredepth depth set heredocstate 2 if heredocstate is 0 if heredoc set heredocstate 1 set heredepth depth if depth > indent.0 unshift! indent depth push! lines INDENT if depth < indent.0 while depth < indent.0 push! lines DEDENT shift! indent if depth isnt indent.0 throw new Error "SAI: incorrect indentation ${!Context lineno}" At last, if we have an actual line of code, prepend the line number and save it. unless isComment and heredocstate and depth<heredepth if heredocstate and depth > heredepth set trimmed (line | limit heredepth, undefined) push! lines '@${lineno+offset}:${trimmed}' End of line loop. Make sure we add enough `DEDENT` markers to reset back to the left margin. while shift! indent push! lines DEDENT Add some blank lines on either end and assemble the array into a string, then return it. unshift! lines '' push! lines '' set lines join! self '\n' return lines ### WrapExtract method Extracts context from a string WrapExtract task given source, location return source.substring(location-100,location)+"<HERE>"+source.substring(location,location+100) ### Contextualize method Transforms a segment of parse-ready code back into indented for inspection Contextualize task given source, offset set linenorex /^\@([0-9]+):(.*)/ set context WrapExtract(source, offset) lines context.split('\n') ind 0 minind 0 newcontext '' Count the ranges of indent in the excerpt.... ply lines as line if line is INDENT .. inc ind elsif line is DEDENT .. dec ind set minind ?< ind set ind to - minind Reformat the excerpt to look something like the original source. ply lines as line if line is INDENT .. inc ind elsif line is DEDENT .. dec ind else set ln linenorex.exec(line) if ln set newcontext + ln.1 + ': ' + (repeat! ' ' ind) + ln.2 + '\n' else set newcontext + (repeat! ' ' ind) + line + '\n' return newcontext ### GetParser method Returns a function that will parse SAI code into Javascript If the grammar file (saigrammar.peg) is newer than the current parser (saigrammar.js), uses PEGjs to recompile the grammar and save the parser. GetParser task try set grammarFile PATH.resolve( PATH.join(DIRNAME, 'saigrammar.peg') ) set parserFile PATH.resolve( PATH.join(DIRNAME, 'saigrammar.js') ) Load the precompiled parser. If it's missing or outdated, rebuild it with `pegjs`. if (not FS.existsSync(parserFile)) ... or (FS.statSync(grammarFile).mtime > FS.statSync(parserFile).mtime) set PEG require('pegjs') grammar FS.readFileSync(grammarFile).toString() mainParser !PEG.generate grammar, fields allowedStartRules list startFile, startExpression output 'source' optimize 'size' trace 0 cache true FS.writeFileSync parserFile, mainParser else set mainParser FS.readFileSync(parserFile).toString() catch set msg ''' SAI: Could not compile saigrammar.peg. Is pegjs available? Is there a syntax error in the grammar? ${error} if grammar set msg WrapExtract(grammar, error.location.start.offset) throw new Error msg Now we have Javascript source for the parser, so compile it. set mainParser ~eval(mainParser) __What we return is a *task* that sets state for the parser, calls the parser we just built, and handles compiler errors.__ return task given rawsource, bound, context The call to `Dedenter` converts human-readable SAI source into format with tagged indents/dedents for easier parsing. set rawsource + '\n\n' source Dedenter(rawsource) try Create the control structure for the parser. set opts fields startrule context ?? 'startFile' :: 'startExpression' bound bound globals config.globals constructs secondclass.constructs pipers secondclass.pipers persist persist filename context failures undefined Call the parser. set js mainParser.parse(source, opts) The parser deals with some syntax errors as _soft failures_ where it can continue parsing to hopefully give the coder more useful feedback. Hard parse errors have already thrown within the parser, so if we have any soft errors, throw now. if opts.failures throw new Error opts.failures catch This is the handler for compile errors. The `meta` variable holds a dump of the file metadata; we'll print this later. set meta to context | total ' ${toUpperCase! key}: ${it}\n' Try to establish where in the preprocessed source things went wrong. Extract the original line number if possible. set location to undefined if error.location set location to trial.start.offset if match! error.message /at:([0-9]+)/ set location to number trial.1 If we found a location, print a code snippet around that location. exists location set error.message to ''' SAI: Syntax error <HERE> in\n${meta} ${Contextualize(source, location)} ${error.message} No location retrievable, so just print the best error message we can. else set error.message to ''' SAI: Error creating prototype in\n${meta} ${error.message} And re-throw. throw error End of error handling. If we're beautifying (and we should be), call the beautifier with some reasonable options. if config.options.beautify set js !Beautify js, fields indent_size 2 preserve_newlines false brace_style 'collapse' Return the final compiled Javascript code. return js End of the task we were defining as the actual parser, and end of the `return` statement that sends that task to our caller. ### GetSourceFromFilename method Load source synchronously. Return contextual information if successful, `undefined` if not. GetSourceFromFilename task given filename try return: success true source toString! FS.readFileSync(filename) context: loader 'SAI.GetSourceFromFilename' path filename workdir PATH.resolve(PATH.dirname(filename)) mtime FS.statSync(filename).mtime fetched new ~Date catch return undefined ### GetSourceFromPaths method This is the basic dynamic loader which fetches SAI source by name from the `config.paths` list of directories. This function gets bound into `config.Loader` GetSourceFromPaths task given name Iterate through the paths we've been provided. set attempts empty ply config.paths as path Iterate through the valid extensions. ply array '.sai.md', '.sai', '.md', ''; as extension set filename PATH.resolve(PATH.join(path, name+extension)) push!attempts filename If we get a result from the file loader, append a bit more context and return. exists GetSourceFromFilename(filename) set .name name set .loader + '/GetSourceFromPaths' return it Return an error with the paths we checked so the user knows what the heck is going on. (Unlike node.) return: success false context ''' SAI.GetSourceFromPaths: cannot load ${name} Tried: ${join!attempts '\n'} Check paths: ${config.paths.join(';')} ### Compile method Create and return a _task_ that will compile parsed SAI source, binding the variables needed to integrate necessary scope and the SAI runtime library. This is quite similar to the compilation technique used by the native `require`. Compile task given source try return new ~Function 'prototype','options','require','$AI','__dirname', source catch debug "ERROR IN HERE" debug source every error debug "${key}: ${it}" throw error ### ProtoGen method Create a fully scoped Javascript prototype for a single object (ignoring inheritance) GetProtogen task given name What is happening here is that we're either returning the protogen from cache, if necessary initializing the cache from the result of a function. return protogens\name initialized !task Try to load the file using the configured Loader. set s1 new ~Date set load config.Loader(name) unless load.success throw new Error 'SAI.GetProtogen: Could not load object ${name}, reason given: ${load.context}' At the end of this _here document_ is a call to `Parse` that will return the compiled Javascript code; the document adds some prefix code before we are ready to hand it off to the JS engine for evaluation. set source ''' var __context=${~JSON.stringify(load.context)}; var __path=require('path'); var __require=function(fn) { try { return require(fn); } catch (e) { try { return require(__path.join(process.cwd(),fn)); } catch (e) { return require(__path.join(process.cwd(),'node_modules',fn)); } } } ${Parse(load.source, undefined, load.context)} Create the function to fabricate the final JS object. set pg !Compile source The prototype generator is a lambda function that wraps the actual evaluation in a directory switcheroo so that in the code being processed, `require` will refer to the directory the source is in, rather than the directory the SAI compiler is in. set protogen to task set cwd to ~process.cwd() ~process.chdir load.context.workdir set ret to pg.apply(null,$$) ~process.chdir cwd return ret //debug source unless protogen throw new Error 'SAI.GetProtogen: Error in generated code ${name}' If the `speedometer` option is set, display time taken to load, compiler, and create the prototype. set s2 new ~Date if config.options.speedometer debug 'SAI: Compiled ${name} in ${s2-s1}ms.' Save the source code in a cache. set sources\name source Return the prototype generator function. return protogen ### Expression method Compile a single SAI expression, out of context except for the runtime library. Expression task given source set js 'return ${Parse(source, undefined, undefined)}' return Compile(js)(@@, blank, ~require, SAILIB, DIRNAME) ### GetAncestors method Build a list of objects that the given object depends on. GetAncestors task given name set heritage array name ancestors blank nodupes blank In order to discern object inheritance, we have to build and invoke each level of the heirarchy so we can retrieve the `__inherits` property of the generated object. Every object we inherit from is checked in turn until we have loaded all of them, created protogens for each, and created an ancestry tree (while ignoring circular references). while shift! heritage as leaf unless nodupes\leaf set nodupes\leaf to true set obj to new ProtoConstructor name set protogen GetProtogen(leaf) set opts: name leaf protogen obj, opts, ~require, SAILIB, DIRNAME obj.Constructor unless obj.isa throw new Error 'SAI GetPrototype: object loaded as ${leaf} does not have an isa type identifier.' with obj.__inherits set ancestors\leaf it ply it heritage.push it return ancestors ### GetPrototype method Given a name, locate all ancestors, create all prototypes, initialize primary prototype object, describe properties, save the result in a cache (SAI.prototypes) and return it. GetPrototype task given name, bindings Return a cached prototype (generating one if needed). return prototypes\name initialized !task set ancestors GetAncestors(name) Create a new, empty prototype. set prototype to new ProtoConstructor name A recursive function that appends the elements of the final object in parent->child order. set Adopt to task given name ply ancestors\name Adopt it set protogen GetProtogen(name) protogen prototype, fields name name;, require, SAILIB, DIRNAME Adopt name ~Object.defineProperty prototype, 'isa', :enumerable false, value prototype.isa; if isas[prototype.isa] throw new Error 'SAI: object defined by ${name} has a duplicate.isa type ${prototype.isa} identical to ${isas[prototype.isa]}' set isas[prototype.isa] name The `finalizePrototype` function in the runtime library uses the lists of attributes in the prototype to lock and freeze attributes and tasks that should not be modifiable. This needs to be done just once. SAILIB.finalizePrototype prototype return prototype ### GetSource method Get full native JS source code for an object, by name. GetSource task as name This works somewhat similarly to `GetPrototype` however instead of building an actual object, we build a monolithic piece of Javascript source that, when `required`, will create the prototype. We start with the ancestors. set source '' ancestors GetAncestors(name) islib to 0 <= name.indexOf('sailib') Adopt task given name ply ancestors\name Adopt it set source + sources\name + '\n' if islib debug 'Building SAILIB specifically to refer to iteself; not requiring sai-library.' Call the recursive helper function to append ancestral source in order. Adopt name Return the Javascript wrapper code; generated source is dropped in the middle. return ''' #!/usr/bin/env node // Javascript source for ${name} transpiled by SAI // "use strict"; var prototype=new function() { this.Constructor=function(){}; this.__tobelocked=[]; this.__tobefrozen=[]; this.__contracts=[]; this.__unverified=true; this.isof={}; return this; }(); var $AI=${islib ?? 'prototype' :: 'require("sai-library")'}; // Generated code follows ${source} // End of generated code $AI.finalizePrototype(prototype); var result=prototype; switch (prototype.isof[prototype.isa].type) { case 'singleton': prototype.Constructor(); if (prototype.Instantiate) prototype.Instantiate(); break; case 'main': result=prototype.constructor(); break; default: result=prototype.constructor; break; } exports=result; try { module.exports=result; } catch(e) {}; return result; ### Require method Return a prototype object by name Require task given name exists GetPrototype(name) as proto exists proto.constructor as ctor return ctor if 'function' is typeof proto return proto throw new Error 'SAI.Require: require could not find a constructor for ${name}' ### Create method Create an object by name (an alternative to using new on what Require gives you) Create task given name, parameters exists GetPrototype(name) set obj ~Object.create(it) if obj.Constructor .. obj.Constructor if obj.Instantiate .. obj.Instantiate.apply obj, parameters return obj throw new Error 'SAI.Create: do not know how to create object ${name}' ### Configure method Update configuration settings individually or as a group. Settings: __paths__: An _array_ of folders that will be searched by the SAI source loader. _Replaces_ the current set of paths. __globals__: An object of _named values_ where names are text that will be matched in the SAI source, and the values are Javascript text that will replace them. _Updates_ the current set of globals, either replacing or adding to ones that already exist. __options__: An object of _named values_ that specify options for the compiler. Updates the current options. __Loader__: A function that you would like to use as the SAI source loader. This will be given an object's name and you shoud return the SAI source for the object. __secondclass__: An _array_ of manifests of second-class language elements (e.g. pipers and constructs) you want added to the compiler. Configure task given c exists c.paths set config.paths it exists c.globals set config.globals | update it exists c.options set config.options | update it exists c.Loader set config.Loader it exists c.secondclass ply it IntegrateConstruct it if config.options.beautify set Beautify require('js-beautify').js_beautify ### RegisterWithNode method Register .sai as an extension with node. (As yet untested. I am not sure people should do this.) RegisterWithNode task set REQUIRE ~require exists REQUIRE.extensions set it.sai to task given module, filename unshift! config.paths PATH.dirname(filename) set result Require(PATH.basename(filename)) shift! config.paths return result else throw new Error "SAI Runtime: Could not find require.extensions to register .sai files with node" ### Environment method Get some information about the runtime environment of SAI modules including 'approved' functions to call. Environment task return: dynamic true static false paths copy config.paths Clean bind Clean Configure bind Configure GetSource bind GetSource RegisterWithNode bind RegisterWithNode Require bind Require