diff --git a/src/lint/collect-header-diagnostics.ts b/src/lint/collect-header-diagnostics.ts new file mode 100644 index 00000000..d44542e1 --- /dev/null +++ b/src/lint/collect-header-diagnostics.ts @@ -0,0 +1,113 @@ +import type { LintingError } from './algorithm-error-reporter-type'; + +import { getLocation, indexWithinElementToTrueLocation } from './utils'; + +const ruleId = 'header-format'; + +export function collectHeaderDiagnostics( + dom: any, + headers: { element: Element; contents: string }[] +) { + let lintingErrors: LintingError[] = []; + + for (let { element, contents } of headers) { + if (!/\(.*\)$/.test(contents) || / Operator \( `[^`]+` \)$/.test(contents)) { + continue; + } + + let name = contents.substring(0, contents.indexOf('(')); + let params = contents.substring(contents.indexOf('(') + 1, contents.length - 1); + + if (!/[\S] $/.test(name)) { + let { line, column } = indexWithinElementToTrueLocation( + getLocation(dom, element), + contents, + name.length - 1 + ); + lintingErrors.push({ + ruleId, + nodeType: element.tagName, + line, + column, + message: 'expected header to have a single space before the argument list', + }); + } + + let nameMatches = [ + // Runtime Semantics: Foo + /^(Runtime|Static) Semantics: [A-Z][A-Za-z0-9/]*\s*$/, + + // Number::foo + /^[A-Z][A-Za-z0-9]*::[a-z][A-Za-z0-9]*\s*$/, + + // [[GetOwnProperty]] + /^\[\[[A-Z][A-Za-z0-9]*\]\]\s*$/, + + // _NativeError_ + /^_[A-Z][A-Za-z0-9]*_\s*$/, + + // CreateForInIterator + // Object.fromEntries + // Array.prototype [ @@iterator ] + /^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z][A-Za-z0-9]*)*( \[ @@[a-z][a-zA-Z]+ \])?\s*$/, + + // %ForInIteratorPrototype%.next + // %TypedArray%.prototype [ @@iterator ] + /^%[A-Z][A-Za-z0-9]*%(\.[A-Za-z][A-Za-z0-9]*)*( \[ @@[a-z][a-zA-Z]+ \])?\s*$/, + ].some(r => r.test(name)); + + if (!nameMatches) { + let { line, column } = indexWithinElementToTrueLocation( + getLocation(dom, element), + contents, + 0 + ); + lintingErrors.push({ + ruleId, + nodeType: element.tagName, + line, + column, + message: `expected operation to have a name like 'Example', 'Runtime Semantics: Foo', 'Example.prop', etc, but found ${JSON.stringify( + name + )}`, + }); + } + + let paramsMatches = + params.match(/\[/g)?.length === params.match(/\]/g)?.length && + [ + // Foo ( ) + /^ $/, + + // Object ( . . . ) + /^ \. \. \. $/, + + // String.raw ( _template_, ..._substitutions_ ) + /^ (_[A-Za-z0-9]+_, )*\.\.\._[A-Za-z0-9]+_ $/, + + // Function ( _p1_, _p2_, … , _pn_, _body_ ) + /^ (_[A-Za-z0-9]+_, )*… (, _[A-Za-z0-9]+_)+ $/, + + // Example ( _foo_ , [ _bar_ ] ) + // Example ( [ _foo_ ] ) + /^ (\[ )?_[A-Za-z0-9]+_(, _[A-Za-z0-9]+_)*( \[ , _[A-Za-z0-9]+_(, _[A-Za-z0-9]+_)*)*( \])* $/, + ].some(r => r.test(params)); + + if (!paramsMatches) { + let { line, column } = indexWithinElementToTrueLocation( + getLocation(dom, element), + contents, + name.length + ); + lintingErrors.push({ + ruleId, + nodeType: element.tagName, + line, + column, + message: `expected parameter list to look like '( _a_, [ , _b_ ] )', '( _foo_, _bar_, ..._baz_ )', '( _foo_, … , _bar_ )', or '( . . . )'`, + }); + } + } + + return lintingErrors; +} diff --git a/src/lint/collect-nodes.ts b/src/lint/collect-nodes.ts index 58cd3cb7..8fd315ff 100644 --- a/src/lint/collect-nodes.ts +++ b/src/lint/collect-nodes.ts @@ -3,6 +3,7 @@ import type { Node as EcmarkdownNode } from 'ecmarkdown'; import { getLocation } from './utils'; export function collectNodes(sourceText: string, dom: any, document: Document) { + let headers: { element: Element; contents: string }[] = []; let mainGrammar: { element: Element; source: string }[] = []; let sdos: { grammar: Element; alg: Element }[] = []; let earlyErrors: { grammar: Element; lists: HTMLUListElement[] }[] = []; @@ -28,6 +29,7 @@ export function collectNodes(sourceText: string, dom: any, document: Document) { let first = node.firstElementChild; if (first !== null && first.nodeName === 'H1') { let title = first.textContent ?? ''; + headers.push({ element: first, contents: title }); if (title.trim() === 'Static Semantics: Early Errors') { let grammar = null; let lists: HTMLUListElement[] = []; @@ -101,5 +103,5 @@ export function collectNodes(sourceText: string, dom: any, document: Document) { } visitCurrentNode(); - return { mainGrammar, sdos, earlyErrors, algorithms }; + return { mainGrammar, headers, sdos, earlyErrors, algorithms }; } diff --git a/src/lint/lint.ts b/src/lint/lint.ts index 4318572d..296e60bb 100644 --- a/src/lint/lint.ts +++ b/src/lint/lint.ts @@ -3,6 +3,7 @@ import { emit } from 'ecmarkdown'; import { collectNodes } from './collect-nodes'; import { collectGrammarDiagnostics } from './collect-grammar-diagnostics'; import { collectAlgorithmDiagnostics } from './collect-algorithm-diagnostics'; +import { collectHeaderDiagnostics } from './collect-header-diagnostics'; import type { Reporter } from './algorithm-error-reporter-type'; /* @@ -16,7 +17,11 @@ There's more to do: https://github.com/tc39/ecmarkup/issues/173 */ export function lint(report: Reporter, sourceText: string, dom: any, document: Document) { - let { mainGrammar, sdos, earlyErrors, algorithms } = collectNodes(sourceText, dom, document); + let { mainGrammar, headers, sdos, earlyErrors, algorithms } = collectNodes( + sourceText, + dom, + document + ); let { grammar, lintingErrors } = collectGrammarDiagnostics( dom, @@ -28,6 +33,8 @@ export function lint(report: Reporter, sourceText: string, dom: any, document: D lintingErrors.push(...collectAlgorithmDiagnostics(dom, sourceText, algorithms)); + lintingErrors.push(...collectHeaderDiagnostics(dom, headers)); + if (lintingErrors.length > 0) { report(lintingErrors, sourceText); return; diff --git a/src/lint/utils.ts b/src/lint/utils.ts index edbcb7f8..2f51f09b 100644 --- a/src/lint/utils.ts +++ b/src/lint/utils.ts @@ -14,6 +14,34 @@ import type { import { Grammar as GrammarFile, SyntaxKind } from 'grammarkdown'; +export function indexWithinElementToTrueLocation( + elementLoc: ReturnType, + string: string, + index: number +) { + let headerLines = string.split('\n'); + let headerLine = 0; + let seen = 0; + while (true) { + if (seen + headerLines[headerLine].length >= index) { + break; + } + seen += headerLines[headerLine].length + 1; // +1 for the '\n' + ++headerLine; + } + let headerColumn = index - seen; + + let line = elementLoc.startTag.line + headerLine; + let column = + headerLine === 0 + ? elementLoc.startTag.col + + (elementLoc.startTag.endOffset - elementLoc.startTag.startOffset) + + headerColumn + : headerColumn + 1; + + return { line, column }; +} + export function grammarkdownLocationToTrueLocation( elementLoc: ReturnType, gmdLine: number, diff --git a/test/lint.js b/test/lint.js index 0bbbb688..76f303c6 100644 --- a/test/lint.js +++ b/test/lint.js @@ -1,6 +1,6 @@ 'use strict'; -let { assertLint, positioned, lintLocationMarker: M } = require('./lint-helpers'); +let { assertLint, assertLintFree, positioned, lintLocationMarker: M } = require('./lint-helpers'); describe('linting whole program', function () { describe('grammar validity', function () { @@ -110,4 +110,114 @@ describe('linting whole program', function () { ); }); }); + + describe('header format', function () { + it('name format', async function () { + await assertLint( + positioned` + +

${M}something: ( )

+ `, + { + ruleId: 'header-format', + nodeType: 'H1', + message: + "expected operation to have a name like 'Example', 'Runtime Semantics: Foo', 'Example.prop', etc, but found \"something: \"", + } + ); + }); + + it('spacing', async function () { + await assertLint( + positioned` + +

Exampl${M}e( )

+ `, + { + ruleId: 'header-format', + nodeType: 'H1', + message: 'expected header to have a single space before the argument list', + } + ); + + await assertLint( + positioned` + +

Example ${M} ( )

+ `, + { + ruleId: 'header-format', + nodeType: 'H1', + message: 'expected header to have a single space before the argument list', + } + ); + }); + + it('arg format', async function () { + await assertLint( + positioned` + +

Example ${M}(_a_)

+ `, + { + ruleId: 'header-format', + nodeType: 'H1', + message: + "expected parameter list to look like '( _a_, [ , _b_ ] )', '( _foo_, _bar_, ..._baz_ )', '( _foo_, … , _bar_ )', or '( . . . )'", + } + ); + }); + + it('legal names', async function () { + await assertLintFree(` + +

Example ( )

+
+ +

Runtime Semantics: Example ( )

+
+ +

The * Operator ( \`*\` )

+
+ +

Number::example ( )

+
+ +

[[Example]] ( )

+
+ +

_Example_ ( )

+
+ +

%Foo%.bar [ @@iterator ] ( )

+
+ `); + }); + + it('legal argument lists', async function () { + await assertLintFree(` + +

Example ( )

+
+ +

Example ( _foo_ )

+
+ +

Example ( [ _foo_ ] )

+
+ +

Date ( _year_, _month_ [ , _date_ [ , _hours_ [ , _minutes_ [ , _seconds_ [ , _ms_ ] ] ] ] ] )

+
+ +

Object ( . . . )

+
+ +

String.raw ( _template_, ..._substitutions_ )

+
+ +

Function ( _p1_, _p2_, … , _pn_, _body_ )

+
+ `); + }); + }); });