UNPKG

@informalsystems/quint

Version:

Core tool for the Quint specification language

372 lines (317 loc) 13.3 kB
import { describe, it } from 'mocha' import { assert } from 'chai' import { readFileSync } from 'fs' import { resolve } from 'path' import JSONbig from 'json-bigint' import { compactSourceMap, parsePhase1fromText, parsePhase2sourceResolution, parsePhase3importAndNameResolution, parsePhase4toposort, } from '../../src/parsing/quintParserFrontend' import { lf } from 'eol' import { newIdGenerator } from '../../src/idGenerator' import { collectIds } from '../util' import { fileSourceResolver } from '../../src/parsing/sourceResolver' import { mkErrorMessage } from '../../src/cliHelpers' import { QuintError } from '../../src' // the name that we are using by default const defaultSourceName = 'moduleName' // read a Quint file from the test data directory function readQuint(name: string): string { const p = resolve(__dirname, '../../testFixture', name + '.qnt') const content = readFileSync(p).toString('utf8') return lf(content) } // read the expected JSON outcome from the test data directory function readJson(name: string): any { const p = resolve(__dirname, '../../testFixture', name + '.json') return JSONbig.parse(readFileSync(p).toString('utf8')) } // parse the text and return all parse errors (of phase 1) function parseErrorsFrom(sourceName: string, code: string): QuintError[] { const gen = newIdGenerator() return parsePhase1fromText(gen, code, sourceName).errors } // read the Quint file and the expected JSON, parse and compare the results function parseAndCompare(artifact: string): void { // read the expected result as JSON const expected = readJson(artifact) // We're not interested in testing the contens of the table here delete expected.table let outputToCompare // read the input from the data directory and parse it const gen = newIdGenerator() const basepath = resolve(__dirname, '../../testFixture') const resolver = fileSourceResolver(new Map(), (path: string) => { // replace the absolute path with a generic mocked path, // so the same fixtures work accross different setups return path.replace(basepath, 'mocked_path/testFixture') }) const mainPath = resolver.lookupPath(basepath, `${artifact}.qnt`) const phase1Result = parsePhase1fromText(gen, readQuint(artifact), mainPath.toSourceName()) const phase2Result = parsePhase2sourceResolution(gen, resolver, mainPath, phase1Result) const phase3Result = parsePhase3importAndNameResolution(phase2Result) const { modules, sourceMap, errors } = parsePhase4toposort(phase3Result) if (errors.length === 0) { // Only check source map if there are no errors. Otherwise, ids generated for error nodes might be missing from the // actual modules const expectedIds = modules.flatMap(m => collectIds(m)).sort() assert.sameDeepMembers([...sourceMap.keys()].sort(), expectedIds, 'expected source map to contain all ids') const expectedSourceMap = readJson(`${artifact}.map`) const sourceMapResult = JSONbig.parse(JSONbig.stringify(compactSourceMap(sourceMap))) assert.deepEqual(sourceMapResult, expectedSourceMap, 'expected source maps to be equal') } // All phases succeeded, check that the module is correclty output outputToCompare = { stage: 'parsing', warnings: [], modules: modules, errors: errors.map(mkErrorMessage(sourceMap)), } // run it through stringify-parse to obtain the same json (due to bigints) const reparsedResult = JSONbig.parse(JSONbig.stringify(outputToCompare)) // compare the JSON trees assert.deepEqual(reparsedResult, expected, 'expected --out data to be equal') } describe('parsing', () => { it('parses empty module', () => { const result = parsePhase1fromText( newIdGenerator(), readQuint('_0001emptyModule'), 'mocked_path/testFixture/_0001emptyModule.qnt' ) const module = { id: 1n, name: 'empty', declarations: [] } assert.deepEqual(result.modules[0], module) }) it('parses SuperSpec', () => { const result = parsePhase1fromText( newIdGenerator(), readQuint('SuperSpec'), 'mocked_path/testFixture/SuperSpec.qnt' ) assert.deepEqual(result.errors, []) }) it('parses SuperSpec correctly', () => { parseAndCompare('SuperSpec') }) it('parses out of order definitions', () => { parseAndCompare('_0099unorderedDefs') }) it('parses sum types', () => { parseAndCompare('_1043sumTypeDecl') }) it('parses polymorphic type declarations', () => { parseAndCompare('_1045polymorphicTypeDecl') }) it('parses match expressions', () => { parseAndCompare('_1044matchExpression') }) }) // instead of testing how errors are produced in json, // we only test the error messages describe('syntax errors', () => { it('unbalanced module definition', () => { const code = 'module empty {' const errors = parseErrorsFrom(defaultSourceName, code) assert.equal(errors.length, 1) assert.equal( errors[0].message, `mismatched input '<EOF>' expecting {'}', 'const', 'var', 'assume', 'type', 'val', 'def', 'pure', 'action', 'run', 'temporal', 'nondet', 'import', 'export', DOCCOMMENT}` ) assert.equal(errors[0].code, 'QNT000') }) it('something unexpected in a module definition', () => { const code = 'module empty { something }' const errors = parseErrorsFrom(defaultSourceName, code) assert.equal(errors.length, 1) assert.equal( errors[0].message, `extraneous input 'something' expecting {'}', 'const', 'var', 'assume', 'type', 'val', 'def', 'pure', 'action', 'run', 'temporal', 'nondet', 'import', 'export', DOCCOMMENT}` ) assert.equal(errors[0].code, 'QNT000') }) it('error in a constant definition', () => { const code = 'module err { const broken }' const errors = parseErrorsFrom(defaultSourceName, code) assert.equal(errors.length, 1) assert.equal(errors[0].message, `mismatched input '}' expecting {':', '::'}`) assert.equal(errors[0].code, 'QNT000') }) it('error on unexpected symbol after expression', () => { // syntax error on: p !! name const code = 'module unexpectedExpr { def access(p) = p !! name }' const errors = parseErrorsFrom(defaultSourceName, code) assert.isAtLeast(errors.length, 1) assert.equal(errors[0].message, `token recognition error at: '!!'`) assert.equal(errors[0].code, 'QNT000') }) it('error on unexpected token', () => { // ~ is an unexpected token const code = 'module unexpectedToken { def access(p) = { p ~ name } }' const errors = parseErrorsFrom(defaultSourceName, code) assert.isAtLeast(errors.length, 1) assert.equal(errors[0].message, `token recognition error at: '~'`) assert.equal(errors[0].code, 'QNT000') }) it('error on unexpected hash', () => { // # is an unexpected token const code = 'module unexpectedToken { def access(p) = { p # name } }' const errors = parseErrorsFrom(defaultSourceName, code) assert.isAtLeast(errors.length, 1) assert.equal(errors[0].message, `token recognition error at: '# '`) assert.equal(errors[0].code, 'QNT000') }) it('error on unexpected hashbang', () => { // hashbang '#!' is only valid at the beginning of a file const code = 'module unexpectedToken { def access(p) = { p #! name } }' const errors = parseErrorsFrom(defaultSourceName, code) assert.isAtLeast(errors.length, 1) assert.equal(errors[0].message, `token recognition error at: '#! name } }'`) assert.equal(errors[0].code, 'QNT000') }) it('error on multiple hashbangs', () => { // only a single hashbang '#!' is valid at the beginning of a file const code = '#!foo\n#!bar\nmodule unexpectedToken { def access = { true } }' const errors = parseErrorsFrom(defaultSourceName, code) assert.isAtLeast(errors.length, 1) assert.equal(errors[0].message, `extraneous input '#!bar\\n' expecting {'module', DOCCOMMENT}`) assert.equal(errors[0].code, 'QNT000') }) it('error on unexpected token "="', () => { // "=" is unexpected const code = 'module unexpectedEq { val errorHere = x = 1 }' const errors = parseErrorsFrom(defaultSourceName, code) assert.equal(errors.length, 1) assert.equal(errors[0].message, `unexpected '=', did you mean '=='?`) assert.equal(errors[0].code, 'QNT006') }) it('error on hanging operator name', () => { // the keyword cardinality is hanging const code = 'module hangingCardinality { def one(S) = { S cardinality } }' const errors = parseErrorsFrom(defaultSourceName, code) assert.isAtLeast(errors.length, 1) assert.equal( errors[0].message, `extraneous input '(' expecting {'}', 'const', 'var', 'assume', 'type', 'val', 'def', 'pure', 'action', 'run', 'temporal', 'nondet', 'import', 'export', DOCCOMMENT}` ) assert.equal(errors[0].code, 'QNT000') }) it('error on double spread', () => { const code = `module spreadError { val rec = { a: 1, b: true } val errorRec = { ...rec, ...rec } }` const errors = parseErrorsFrom(defaultSourceName, code) assert.equal(errors.length, 1) assert.equal(errors[0].message, '... may be used once in { ...record, <fields> }') assert.equal(errors[0].code, 'QNT012') }) it('error on single quotes in import', () => { // we should use double quotes const code = `module singleQuotes { import I.* from './_1025importeeWithError' }` const errors = parseErrorsFrom(defaultSourceName, code) assert.isAtLeast(errors.length, 1) assert.equal(errors[0].message, `mismatched input ''' expecting STRING`) assert.equal(errors[0].code, 'QNT000') }) it('error on type declarations with undeclared variables', () => { const code = `module singleQuotes { type T = (List[a], Set[b]) }` const [error] = parseErrorsFrom(defaultSourceName, code) assert.deepEqual(error.code, 'QNT014') assert.deepEqual( error.message, `the type variables a, b are unbound. E.g., in type T = List[a] type variable 'a' is unbound. To fix it, write type T[a] = List[a]` ) }) it('error on type declarations with undeclared variables', () => { const code = `module mapSyntax { type WrongMap = Map[int, str] }` const [error] = parseErrorsFrom(defaultSourceName, code) assert.deepEqual(error.code, 'QNT015') assert.deepEqual(error.message, `Use 'int -> str' instead of 'Map[int, str]' for map types`) assert.deepEqual(error.data?.fix, { kind: 'replace', original: 'Map[int, str]', replacement: 'int -> str', }) }) it('error on using reserved keywords', () => { const code = `module reservedKeyword { type Reserved = { and: int, export: bool } }` const [error1, error2] = parseErrorsFrom(defaultSourceName, code) assert.deepEqual(error1.code, 'QNT008') assert.deepEqual(error1.message, `Reserved keyword 'and' cannot be used as an identifier.`) assert.deepEqual(error2.code, 'QNT008') assert.deepEqual(error2.message, `Reserved keyword 'export' cannot be used as an identifier.`) }) }) // Test the JSON error output. Most of the tests here should migrate to the // above test suite called 'syntax errors' or other test suites for the later // phases. It is sufficient to have a few tests that compare JSON. For the // rest, we are interested in checking the error messages, not the JSON files. describe('parse errors', () => { it('error on unresolved name', () => { parseAndCompare('_1010undefinedName') }) it('error on unresolved scoped name', () => { parseAndCompare('_1011nameOutOfScope') }) it('error on unresolved type alias', () => { parseAndCompare('_1012unknownType') }) it('error on unresolved type alias inside let', () => { parseAndCompare('_1013unknownTypeLetIn') }) it('error on conflicting names', () => { parseAndCompare('_1014conflictingNames') }) it('parses single import from ', () => { parseAndCompare('_1021importee1') }) it('parses import from transtively', () => { parseAndCompare('_1020importFrom') }) it('errors on incorrect import', () => { parseAndCompare('_1023importFromUnresolved') }) it('errors on cyclic imports', () => { parseAndCompare('_1026importCycleA') }) it('a module with a constant', () => { parseAndCompare('_1030const') }) it('an instance', () => { parseAndCompare('_1031instance') }) it('docstrings', () => { parseAndCompare('_1032docstrings') }) // The test below needs a fix, see: // // https://github.com/informalsystems/quint/issues/378 //it('error on top-level nondet', () => { // parseAndCompare('_1015noToplevelNondet') //}) it('error on overriding values that are not constants', () => { parseAndCompare('_1016nonConstOverride') }) it('success on keywords that are allowed as identifiers', () => { parseAndCompare('_1017keywordsAsIdentifiers') }) it('error on cyclic definitions', () => { parseAndCompare('_0100cyclicDefs') }) it('error on accidental recursion', () => { parseAndCompare('_0101noRecursion') }) it('errors on invalid record fields', () => { parseAndCompare('_1042qualifiersInRecordsFieldsError') }) it('error on map syntax', () => { parseAndCompare('_1070mapSyntax') }) })