@informalsystems/quint
Version:
Core tool for the Quint specification language
400 lines • 20 kB
JavaScript
"use strict";
/*
* The frontend to the Quint parser, which is generated with Antlr4.
*
* Igor Konnov, Gabriela Moreira, Shon Feder, 2021-2023
*
* Copyright 2021-2023 Informal Systems
* Licensed under the Apache License, Version 2.0.
* See LICENSE in the project root for license information.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.parseDefOrThrow = exports.parse = exports.compactSourceMap = exports.parsePhase4toposort = exports.parsePhase3importAndNameResolution = exports.parsePhase2sourceResolution = exports.parsePhase1fromText = exports.parseExpressionOrDeclaration = void 0;
const antlr4ts_1 = require("antlr4ts");
const ParseTreeWalker_1 = require("antlr4ts/tree/ParseTreeWalker");
const immutable_1 = require("immutable");
const QuintLexer_1 = require("../generated/QuintLexer");
const p = __importStar(require("../generated/QuintParser"));
const quintIr_1 = require("../ir/quintIr");
const idGenerator_1 = require("../idGenerator");
const ToIrListener_1 = require("./ToIrListener");
const resolver_1 = require("../names/resolver");
const sourceResolver_1 = require("./sourceResolver");
const callgraph_1 = require("../static/callgraph");
const IRVisitor_1 = require("../ir/IRVisitor");
const toposort_1 = require("../static/toposort");
const lodash_1 = require("lodash");
/**
* Try parsing text as an expression or a top-level declaration.
*
* @param text input text
* @param sourceLocation a textual description of the source
* @returns the parsing result
*/
function parseExpressionOrDeclaration(text, sourceLocation, idGenerator, sourceMap) {
const errors = [];
const parser = setupParser(text, sourceLocation, errors, sourceMap, idGenerator);
const tree = parser.declarationOrExpr();
const listener = new ExpressionOrDeclarationListener(sourceLocation, idGenerator);
// Use an existing source map as a starting point.
listener.sourceMap = sourceMap;
ParseTreeWalker_1.ParseTreeWalker.DEFAULT.walk(listener, tree);
errors.push(...listener.errors);
return errors.length > 0 ? { kind: 'error', errors: errors } : listener.result;
}
exports.parseExpressionOrDeclaration = parseExpressionOrDeclaration;
/**
* Phase 1 of the Quint parser. Read a string in the Quint syntax and produce the IR.
* Note that the IR may be ill-typed and some names may be unresolved.
* The main goal of this pass is to translate a sequence of characters into IR.
*/
function parsePhase1fromText(idGen, text, sourceLocation) {
const errors = [];
const listener = new ToIrListener_1.ToIrListener(sourceLocation, idGen);
const parser = setupParser(text, sourceLocation, errors, listener.sourceMap, idGen);
// run the parser
const tree = parser.modules();
// wrap this in a try catch, since we are running the parser even if there are
// errors after the previous command.
try {
// walk through the AST and construct the IR
ParseTreeWalker_1.ParseTreeWalker.DEFAULT.walk(listener, tree);
}
catch (e) {
if (errors.length === 0) {
throw e;
}
console.debug(`[DEBUG] ignoring listener exception in favor of parse errors. Exception: ${e}`);
// ignore the exception, we already have errors to report.
//
// This happens in a situation where the first part of parsing (constructing
// the AST) has finished with errors, but we still want to try and build an
// IR out of it in order to collect as many errors as we can (from this and subsequent
// phases). So, we try to proceed, but it's fine if it doesn't work out.
//
// It is safe to ignore errors here because, normally, we wouldn't even run
// this code after parse failures. However, if we want to run subsequent
// phases on top of the generated IR, it is important to consider that it is
// only a partial result and might have undefined components or be incomplete.
}
return { errors: errors.concat(listener.errors), modules: listener.modules, sourceMap: listener.sourceMap };
}
exports.parsePhase1fromText = parsePhase1fromText;
/**
* Phase 2 of the Quint parser. Go over each declaration of the form
* `import ... from '<path>'`, do the following:
*
* - parse the modules that are referenced by each path,
* - add the parsed modules.
*
* Cyclic dependencies among different files are reported as errors.
*/
function parsePhase2sourceResolution(idGen, sourceResolver, mainPath, mainPhase1Result) {
// We accumulate the source map over all files here.
let sourceMap = new Map(mainPhase1Result.sourceMap);
// The list of modules that have not been processed yet. Each element of
// the list carries the module to be processed and the trail of sources that
// led to this module. The construction is similar to the worklist algorithm:
// https://en.wikipedia.org/wiki/Reaching_definition#Worklist_algorithm
const worklist = mainPhase1Result.modules.map(m => [m, [mainPath]]);
// Collect modules produced by every source.
const sourceToModules = new Map();
// Collect visited paths, so we don't have to load the same file twice.
// Some filesystems are case-insensitive, whereas some are case sensitive.
// To prevent errors like #1194 from happening, we store both the
// original filename and its lower case version. If the user uses the same
// filename in different registers, we report an error. Otherwise, it would be
// quite hard to figure out tricky naming errors in the case-sensitive
// filesystems. We could also collect hashes of the files instead of
// lowercase filenames, but this looks a bit like overkill at the moment.
const visitedPaths = new Map();
// Assign a rank to every module. The higher the rank,
// the earlier the module should appear in the list of modules.
sourceToModules.set(mainPath.normalizedPath, mainPhase1Result.modules);
visitedPaths.set(mainPath.normalizedPath.toLocaleLowerCase(), mainPath.normalizedPath);
while (worklist.length > 0) {
const [importer, pathTrail] = worklist.splice(0, 1)[0];
for (const decl of importer.declarations) {
if ((decl.kind === 'import' || decl.kind === 'instance') && decl.fromSource) {
const importerPath = pathTrail[pathTrail.length - 1];
const stemPath = sourceResolver.stempath(importerPath);
const importeePath = sourceResolver.lookupPath(stemPath, decl.fromSource + '.qnt');
const importeeNormalized = importeePath.normalizedPath;
const importeeLowerCase = importeeNormalized.toLowerCase();
if (visitedPaths.has(importeeLowerCase)) {
if (visitedPaths.get(importeeLowerCase) === importeeNormalized) {
// simply skip this import without parsing the same file twice
continue;
}
else {
// The source has been parsed already, but:
// - Either the same file is imported via paths in different cases, or
// - Two different files are imported via case-sensitive paths.
// Ask the user to disambiguate.
const original = [...visitedPaths.values()].find(name => name.toLowerCase() === importeeLowerCase && name !== importeeLowerCase) ?? importeeLowerCase;
const err = {
code: 'QNT408',
message: `Importing two files that only differ in case: ${original} vs. ${importeeNormalized}. Choose one way.`,
reference: decl.id,
};
return { ...mainPhase1Result, errors: mainPhase1Result.errors.concat([err]), sourceMap };
}
}
// try to load the source code
const errorOrText = sourceResolver.load(importeePath);
if (errorOrText.isLeft()) {
// failed to load the imported source
const err = {
code: 'QNT013',
message: `import ... from '${decl.fromSource}': could not load`,
reference: decl.id,
};
return { ...mainPhase1Result, errors: mainPhase1Result.errors.concat([err]), sourceMap };
}
// try to parse the source code
const parseResult = parsePhase1fromText(idGen, errorOrText.value, importeePath.toSourceName());
// all good: add the new modules to the worklist, and update the source map
const newModules = Array.from(parseResult.modules).reverse();
newModules.forEach(m => {
worklist.push([m, pathTrail.concat([importeePath])]);
});
sourceToModules.set(importeePath.normalizedPath, newModules);
visitedPaths.set(importeeLowerCase, importeeNormalized);
sourceMap = new Map([...sourceMap, ...parseResult.sourceMap]);
}
}
}
// Get all the modules and sort them according to the rank (the higher, the earlier)
let allModules = [];
for (const mods of sourceToModules.values()) {
allModules = allModules.concat(mods);
}
// sort the modules
const sortingResult = sortModules(allModules);
return {
...mainPhase1Result,
errors: mainPhase1Result.errors.concat(sortingResult.errors),
modules: sortingResult.modules,
sourceMap,
};
}
exports.parsePhase2sourceResolution = parsePhase2sourceResolution;
/**
* Sort modules according to their imports, that is, importees should appear before importers.
* @param modules the modules to sort
* @return a structure that contains errors (if any were found) and the modules (sorted if no errors)
*/
function sortModules(modules) {
// iterate over the modules to construct:
// - the map from module identifiers to modules
// - the map from module names to modules
// - the set of modules with duplicate names, if there are any
const [idToModule, nameToModule, duplicates] = modules.reduce(([idMap, namesMap, dups], mod) => {
const newIdMap = idMap.set(mod.id, mod);
const newNamesMap = namesMap.set(mod.name, mod);
const newDups = namesMap.has(mod.name) ? dups.add(mod) : dups;
return [newIdMap, newNamesMap, newDups];
}, [(0, immutable_1.Map)(), (0, immutable_1.Map)(), (0, immutable_1.Set)()]);
if (!duplicates.isEmpty()) {
const errors = duplicates.toArray().map(mod => {
return {
code: 'QNT101',
message: `Multiple modules conflict on the same name: ${mod.name}`,
reference: mod.id,
};
});
return { errors, modules };
}
// create the import graph
let edges = (0, immutable_1.Map)();
for (const mod of modules) {
let imports = (0, immutable_1.Set)();
for (const decl of mod.declarations) {
// We only keep track of imports and instances, but not of the exports:
// - Exports flow in the opposite direction of imports.
// - An export cannot be used without a corresponding import.
if (decl.kind === 'import' || decl.kind === 'instance') {
if (!nameToModule.has(decl.protoName)) {
const err = {
code: 'QNT405',
message: `Module ${mod.name} imports an unknown module ${decl.protoName}`,
reference: decl.id,
};
return { errors: [err], modules };
}
imports = imports.add(nameToModule.get(decl.protoName).id);
}
// add all imports as the edges from mod
edges = edges.set(mod.id, imports);
}
}
// sort the modules with toposort
const result = (0, toposort_1.toposort)(edges, modules);
if (result.cycles.isEmpty()) {
return { errors: [], modules: result.sorted };
}
else {
// note that the modules in the cycle are not always sorted according to the imports
const cycle = result.cycles.map(id => idToModule.get(id)?.name).join(', ');
const err = {
code: 'QNT098',
message: `Cyclic imports among: ${cycle}`,
reference: result.cycles.first(),
};
return { errors: [err], modules };
}
}
/**
* Phase 3 of the Quint parser. Assuming that all external sources have been resolved,
* resolve imports and names. Read the IR and check that all names are defined.
* Note that the IR may be ill-typed.
*/
function parsePhase3importAndNameResolution(phase2Data) {
const result = (0, resolver_1.resolveNames)(phase2Data.modules);
return { ...phase2Data, ...result, errors: phase2Data.errors.concat(result.errors) };
}
exports.parsePhase3importAndNameResolution = parsePhase3importAndNameResolution;
/**
* Phase 4 of the Quint parser. Sort all declarations in the topologocal order,
* that is, every name should be defined before it is used.
*/
function parsePhase4toposort(phase3Data) {
// topologically sort all declarations in each module
const context = (0, callgraph_1.mkCallGraphContext)(phase3Data.modules);
const errors = phase3Data.errors;
const modules = phase3Data.modules.map(mod => {
const visitor = new callgraph_1.CallGraphVisitor(phase3Data.table, context);
(0, IRVisitor_1.walkModule)(visitor, mod);
const result = (0, toposort_1.toposort)(visitor.graph, mod.declarations);
errors.push(...result.cycles.toArray().map((id) => {
return {
code: 'QNT099',
message: 'Found cyclic declarations. Use fold and foldl instead of recursion',
reference: id,
};
}));
return { ...mod, declarations: result.sorted };
});
return { ...phase3Data, modules, errors };
}
exports.parsePhase4toposort = parsePhase4toposort;
function compactSourceMap(sourceMap) {
// Collect all sources in order to index them
const sources = Array.from(sourceMap.values()).map(loc => loc.source);
// Initialized two structures to be outputted
const compactedSourceMap = new Map();
const sourcesIndex = new Map();
// Build a compacted version of the source map with array elements
sourceMap.forEach((value, key) => {
compactedSourceMap.set(key, [sources.indexOf(value.source), value.start, value.end ? value.end : {}]);
});
// Build an index from ids to source
sources.forEach(source => {
sourcesIndex.set(sources.indexOf(source), source);
});
return { sourceIndex: Object.fromEntries(sourcesIndex), map: Object.fromEntries(compactedSourceMap) };
}
exports.compactSourceMap = compactSourceMap;
/**
* Parses a Quint code string and returns a `ParseResult` containing the result of all three parsing phases.
*
* @param idGen An `IdGenerator` instance to generate unique IDs for parsed elements.
* @param sourceLocation A string describing the source location of the code being parsed.
* @param mainPath The main source lookup path for resolving imports.
* @param code The Quint code string to parse.
* @param sourceCode Optionally a map of previously parsed files, to be updated by this function
* @returns A `ParseResult` containing the result of all three parsing phases.
*/
function parse(idGen, sourceLocation, mainPath, code, sourceCode = new Map()) {
return (0, lodash_1.flow)([
() => parsePhase1fromText(idGen, code, sourceLocation),
phase1Data => {
const resolver = (0, sourceResolver_1.fileSourceResolver)(sourceCode);
return parsePhase2sourceResolution(idGen, resolver, mainPath, phase1Data);
},
parsePhase3importAndNameResolution,
parsePhase4toposort,
])();
}
exports.parse = parse;
function parseDefOrThrow(text, idGen, sourceMap) {
const result = parseExpressionOrDeclaration(text, '<builtins>', idGen ?? (0, idGenerator_1.newIdGenerator)(), sourceMap ?? new Map());
if (result.kind === 'declaration' && (0, quintIr_1.isDef)(result.decls[0])) {
return result.decls[0];
}
else {
const msg = result.kind === 'error' ? result.errors.join('\n') : `Expected a definition, got ${result.kind}`;
throw new Error(`${msg}, parsing ${text}`);
}
}
exports.parseDefOrThrow = parseDefOrThrow;
// setup a Quint parser, so it can be used to parse from various non-terminals
function setupParser(text, sourceLocation, errors, sourceMap, idGen) {
// error listener to report lexical and syntax errors
const errorListener = {
syntaxError: (_recognizer, offendingSymbol, line, charPositionInLine, msg) => {
const id = idGen.nextId();
const len = offendingSymbol ? offendingSymbol.stopIndex - offendingSymbol.startIndex : 0;
const index = offendingSymbol ? offendingSymbol.startIndex : 0;
const start = { line: line - 1, col: charPositionInLine, index };
const end = { line: line - 1, col: charPositionInLine + len, index: index + len };
const loc = { source: sourceLocation, start, end };
sourceMap.set(id, loc);
const code = msg.match(/QNT\d\d\d/)?.[0] ?? 'QNT000';
errors.push({ code, message: msg.replace(`[${code}] `, ''), reference: id });
},
};
// Create the lexer and parser
const inputStream = antlr4ts_1.CharStreams.fromString(text);
const lexer = new QuintLexer_1.QuintLexer(inputStream);
// remove the console listener and add our listener
lexer.removeErrorListeners();
lexer.addErrorListener(errorListener);
const tokenStream = new antlr4ts_1.CommonTokenStream(lexer);
const parser = new p.QuintParser(tokenStream);
// remove the console listener and add our listener
parser.removeErrorListeners();
parser.addErrorListener(errorListener);
return parser;
}
// A simple listener to parse a declaration or expression
class ExpressionOrDeclarationListener extends ToIrListener_1.ToIrListener {
exitDeclarationOrExpr(ctx) {
if (ctx.declaration()) {
const prevDecls = this.result?.kind === 'declaration' ? this.result.decls : [];
const decls = this.declarationStack;
this.result = { kind: 'declaration', decls: [...prevDecls, ...decls] };
}
else if (ctx.expr()) {
const expr = this.exprStack[this.exprStack.length - 1];
this.result = { kind: 'expr', expr };
}
else {
this.result = { kind: 'none' };
}
}
}
//# sourceMappingURL=quintParserFrontend.js.map