diff --git a/css/elements.css b/css/elements.css index 183c1af7..e69c92b1 100644 --- a/css/elements.css +++ b/css/elements.css @@ -35,6 +35,22 @@ a:hover { color: #239dee; } +a.e-user-code { + position: relative; +} + +a.e-user-code::before { + display: none; + color: brown; + position: absolute; + bottom: -0.9em; + left: 0; + content: 'UC'; + font-style: italic; + font-weight: bold; + font-size: small; +} + code { font-weight: bold; font-family: Consolas, Monaco, monospace; diff --git a/js/menu.js b/js/menu.js index 260b2fbb..eda4e3e9 100644 --- a/js/menu.js +++ b/js/menu.js @@ -964,6 +964,8 @@ function makeLinkToId(id) { return (targetSec === 'index' ? './' : targetSec + '.html') + hash; } +let stylesheetWorkaroundForCanCallUserCodeAnnotation; + function doShortcut(e) { if (!(e.target instanceof HTMLElement)) { return; @@ -973,7 +975,10 @@ function doShortcut(e) { if (name === 'textarea' || name === 'input' || name === 'select' || target.isContentEditable) { return; } - if (e.key === 'm' && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey && usesMultipage) { + if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { + return; + } + if (e.key === 'm' && usesMultipage) { let pathParts = location.pathname.split('/'); let hash = location.hash; if (pathParts[pathParts.length - 2] === 'multipage') { @@ -990,6 +995,13 @@ function doShortcut(e) { } else { location = 'multipage/' + hash; } + } else if (e.key === 'u') { + if (stylesheetWorkaroundForCanCallUserCodeAnnotation.innerText === '') { + stylesheetWorkaroundForCanCallUserCodeAnnotation.textContent = + 'a.e-uc::before { display: block !important; }'; + } else { + stylesheetWorkaroundForCanCallUserCodeAnnotation.textContent = ''; + } } } @@ -1017,4 +1029,6 @@ document.addEventListener('keypress', doShortcut); document.addEventListener('DOMContentLoaded', () => { Toolbox.init(); referencePane.init(); + stylesheetWorkaroundForCanCallUserCodeAnnotation = document.createElement('style'); + document.head.appendChild(stylesheetWorkaroundForCanCallUserCodeAnnotation); }); diff --git a/src/Clause.ts b/src/Clause.ts index 58cde076..b0272e06 100644 --- a/src/Clause.ts +++ b/src/Clause.ts @@ -20,9 +20,11 @@ export default class Clause extends Builder { subclauses: Clause[]; number: string; aoid: string | null; + type: string | null; notes: Note[]; editorNotes: Note[]; examples: Example[]; + effects: string[]; constructor(spec: Spec, node: HTMLElement, parent: Clause, number: string) { super(spec, node); @@ -33,6 +35,7 @@ export default class Clause extends Builder { this.notes = []; this.editorNotes = []; this.examples = []; + this.effects = []; // namespace is either the entire spec or the parent clause's namespace. let parentNamespace = spec.namespace; @@ -53,6 +56,11 @@ export default class Clause extends Builder { this.aoid = node.id; } + this.type = node.getAttribute('type'); + if (this.type === '') { + this.type = null; + } + let header = this.node.firstElementChild; while (header != null && header.tagName === 'SPAN' && header.children.length === 0) { // skip oldids @@ -87,7 +95,7 @@ export default class Clause extends Builder { } // if we find such a DL, treat this as a structured header - const type = this.node.getAttribute('type'); + const type = this.type; const { name, formattedHeader, formattedParams, formattedReturnType } = parseStructuredHeaderH1( this.spec, @@ -120,7 +128,7 @@ export default class Clause extends Builder { header.innerHTML = formattedHeader; } - const { description, for: _for } = parseStructuredHeaderDl(this.spec, type, dl); + const { description, for: _for, effects } = parseStructuredHeaderDl(this.spec, type, dl); const paras = formatPreamble( this.spec, @@ -158,6 +166,14 @@ export default class Clause extends Builder { this.node.setAttribute('aoid', name); this.aoid = name; } + + this.effects = effects; + for (const effect of effects) { + if (!this.spec._effectWorklist.has(effect)) { + this.spec._effectWorklist.set(effect, []); + } + this.spec._effectWorklist.get(effect)!.push(this); + } } buildNotes() { @@ -184,6 +200,16 @@ export default class Clause extends Builder { } } + canHaveEffect(effectName: string) { + // The following effects are runtime only: + // + // user-code: Only runtime can call user code. + if (this.title !== null && this.title.startsWith('Static Semantics:')) { + if (effectName === 'user-code') return false; + } + return true; + } + static async enter({ spec, node, clauseStack, clauseNumberer }: Context) { if (!node.id) { spec.warn({ diff --git a/src/Spec.ts b/src/Spec.ts index e1e3130f..da6af931 100644 --- a/src/Spec.ts +++ b/src/Spec.ts @@ -256,6 +256,17 @@ function isEmuImportElement(node: Node): node is EmuImportElement { return node.nodeType === 1 && node.nodeName === 'EMU-IMPORT'; } +function maybeAddClauseToEffectWorklist(effectName: string, clause: Clause, worklist: Clause[]) { + if ( + !worklist.includes(clause) && + clause.canHaveEffect(effectName) && + !clause.effects.includes(effectName) + ) { + clause.effects.push(effectName); + worklist.push(clause); + } +} + /*@internal*/ export default class Spec { spec: this; @@ -290,6 +301,8 @@ export default class Spec { }[]; _prodRefs: ProdRef[]; _textNodes: { [s: string]: [TextNodeContext] }; + _effectWorklist: Map; + _effectfulAOs: Map; refsByClause: { [refId: string]: [string] }; private _fetch: (file: string, token: CancellationToken) => PromiseLike; @@ -332,6 +345,8 @@ export default class Spec { this._ntStringRefs = []; this._prodRefs = []; this._textNodes = {}; + this._effectWorklist = new Map(); + this._effectfulAOs = new Map(); this.refsByClause = Object.create(null); this.processMetadata(); @@ -455,6 +470,8 @@ export default class Spec { this.autolink(); + this.log('Propagating can-call-user-code annotations...'); + this.propagateEffects(); this.log('Linking xrefs...'); this._xrefs.forEach(xref => xref.build()); this.log('Linking non-terminal references...'); @@ -652,6 +669,50 @@ export default class Spec { return true; } + private propagateEffects() { + for (const [effectName, worklist] of this._effectWorklist) { + this.propagateEffect(effectName, worklist); + } + } + + private propagateEffect(effectName: string, worklist: Clause[]) { + const usersOfAoid: Map> = new Map(); + for (const xref of this._xrefs) { + if (xref.clause == null || xref.aoid == null) continue; + if (!xref.canHaveEffect(effectName)) continue; + + if (xref.hasAddedEffect(effectName)) { + maybeAddClauseToEffectWorklist(effectName, xref.clause, worklist); + } + + const usedAoid = xref.aoid; + if (!usersOfAoid.has(usedAoid)) { + usersOfAoid.set(usedAoid, new Set()); + } + usersOfAoid.get(usedAoid)!.add(xref.clause); + } + + while (worklist.length !== 0) { + const clause = worklist.shift() as Clause; + const aoid = clause.aoid; + if (aoid == null || !usersOfAoid.has(aoid)) { + continue; + } + + this._effectfulAOs.set(aoid, clause); + for (const userClause of usersOfAoid.get(aoid)!) { + maybeAddClauseToEffectWorklist(effectName, userClause, worklist); + } + } + } + + public getEffectsByAoid(aoid: string): string[] | null { + if (this._effectfulAOs.has(aoid)) { + return this._effectfulAOs.get(aoid)!.effects; + } + return null; + } + private async buildMultipage(wrapper: Element, tocEles: Element[], jsSha: string) { let stillIntro = true; const introEles = []; diff --git a/src/Xref.ts b/src/Xref.ts index 84d679f9..f4783c82 100644 --- a/src/Xref.ts +++ b/src/Xref.ts @@ -4,15 +4,19 @@ import type * as Biblio from './Biblio'; import type Clause from './Clause'; import Builder from './Builder'; +import { validateEffects } from './utils'; /*@internal*/ export default class Xref extends Builder { namespace: string; href: string; aoid: string; + isInvocation: boolean; clause: Clause | null; id: string; entry: Biblio.BiblioEntry | undefined; + addEffects: string[] | null; + suppressEffects: string[] | null; static elements = ['EMU-XREF']; @@ -30,6 +34,61 @@ export default class Xref extends Builder { this.aoid = aoid; this.clause = clause; this.id = node.getAttribute('id')!; + this.isInvocation = node.hasAttribute('is-invocation'); + node.removeAttribute('is-invocation'); + + // Check if there's metadata adding or suppressing effects + this.addEffects = null; + this.suppressEffects = null; + if (node.parentElement && node.parentElement.tagName === 'EMU-META') { + if (node.parentElement.hasAttribute('effects')) { + const addEffects = node.parentElement.getAttribute('effects')!.split(','); + if (addEffects.length !== 0) { + this.addEffects = validateEffects(spec, addEffects, node.parentElement); + } + } + if (node.parentElement.hasAttribute('suppress-effects')) { + const suppressEffects = node.parentElement.getAttribute('suppress-effects')!.split(','); + if (suppressEffects.length !== 0) { + this.suppressEffects = validateEffects(spec, suppressEffects, node.parentElement); + } + } + if (this.addEffects !== null && this.suppressEffects !== null) { + for (const e of this.addEffects) { + if (this.suppressEffects.includes(e)) { + throw new Error('effect suppression is contradictory'); + } + } + for (const e of this.suppressEffects) { + if (this.addEffects.includes(e)) { + throw new Error('effect suppression is contradictory'); + } + } + } + + // Strip an outer if present + const children = node.parentElement.childNodes; + node.parentElement.replaceWith(...children); + } + } + + canHaveEffect(effectName: string) { + if (!this.isInvocation) return false; + if (this.clause && !this.clause.canHaveEffect(effectName)) { + return false; + } + if (this.suppressEffects !== null) { + return !this.suppressEffects.includes(effectName); + } + return true; + } + + hasAddedEffect(effectName: string) { + if (!this.isInvocation) return false; + if (this.addEffects !== null) { + return this.addEffects.includes(effectName); + } + return false; } static async enter({ node, spec, clauseStack }: Context) { @@ -141,7 +200,34 @@ export default class Xref extends Builder { this.entry = spec.biblio.byAoid(aoid, namespace); if (this.entry) { - buildAOLink(node, this.entry); + let effects = null; + let classNames = null; + + if (this.isInvocation) { + effects = spec.getEffectsByAoid(aoid); + } + if (this.addEffects !== null) { + if (effects !== null) { + effects.push(...this.addEffects); + } else { + effects = this.addEffects; + } + } + + if (effects) { + if (this.suppressEffects !== null) { + effects = effects.filter(e => !this.suppressEffects!.includes(e)); + } + if (effects.length !== 0) { + const parentClause = this.clause; + effects = parentClause ? effects.filter(e => parentClause.canHaveEffect(e)) : effects; + if (effects.length !== 0) { + classNames = effects.map(e => `e-${e}`).join(' '); + } + } + } + + buildAOLink(node, this.entry, classNames); return; } @@ -179,11 +265,11 @@ function buildProductionLink(xref: Element, entry: Biblio.ProductionBiblioEntry) } } -function buildAOLink(xref: Element, entry: Biblio.BiblioEntry) { +function buildAOLink(xref: Element, entry: Biblio.BiblioEntry, classNames: string | null) { if (xref.textContent!.trim() === '') { - xref.innerHTML = buildXrefLink(entry, xref.getAttribute('aoid')); + xref.innerHTML = buildXrefLink(entry, xref.getAttribute('aoid'), classNames); } else { - xref.innerHTML = buildXrefLink(entry, xref.innerHTML); + xref.innerHTML = buildXrefLink(entry, xref.innerHTML, classNames); } } @@ -276,6 +362,11 @@ function buildStepLink(spec: Spec, xref: Element, entry: Biblio.StepBiblioEntry) xref.innerHTML = buildXrefLink(entry, text); } -function buildXrefLink(entry: Biblio.BiblioEntry, contents: string | number | undefined | null) { - return '' + contents + ''; +function buildXrefLink( + entry: Biblio.BiblioEntry, + contents: string | number | undefined | null, + classNames: string | null = null +) { + const classSnippet = classNames == null ? '' : ' class="' + classNames + '"'; + return `${contents}`; } diff --git a/src/autolinker.ts b/src/autolinker.ts index ba8bf5ee..8b67529f 100644 --- a/src/autolinker.ts +++ b/src/autolinker.ts @@ -42,7 +42,7 @@ export function autolink( const spec = clause.spec; const template = spec.doc.createElement('template'); const content = escape(node.textContent!); - const autolinked = content.replace(replacer, match => { + const autolinked = content.replace(replacer, (match, offset) => { const entry = autolinkmap[narrowSpace(match)]; if (!entry) { return match; @@ -56,9 +56,20 @@ export function autolink( } if (entry.aoid) { - return '' + match + ''; + let isInvocationAttribute = ''; + // Matches function-style invocation with parentheses and SDO-style 'of' + // invocation. + if ( + content[offset + match.length] === '(' || + (content[offset + match.length] === ' ' && + content[offset + match.length + 1] === 'o' && + content[offset + match.length + 2] === 'f') + ) { + isInvocationAttribute = ' is-invocation'; + } + return `${match}`; } else { - return '' + match + ''; + return `${match}`; } }); diff --git a/src/header-parser.ts b/src/header-parser.ts index 274386f2..45ecfd27 100644 --- a/src/header-parser.ts +++ b/src/header-parser.ts @@ -1,5 +1,5 @@ import type Spec from './Spec'; -import { offsetToLineAndColumn } from './utils'; +import { offsetToLineAndColumn, validateEffects } from './utils'; export function parseStructuredHeaderH1( spec: Spec, @@ -224,9 +224,10 @@ export function parseStructuredHeaderDl( spec: Spec, type: string | null, dl: Element -): { description: Element | null; for: Element | null } { +): { description: Element | null; for: Element | null; effects: string[] } { let description = null; let _for = null; + let effects: string[] = []; for (let i = 0; i < dl.children.length; ++i) { const dt = dl.children[i]; if (dt.tagName !== 'DT') { @@ -285,6 +286,17 @@ export function parseStructuredHeaderDl( } break; } + case 'effects': { + // The dd contains a comma-separated list of effects. + if (dd.textContent !== null) { + effects = validateEffects( + spec, + dd.textContent.split(',').map(c => c.trim()), + dd + ); + } + break; + } case '': { spec.warn({ type: 'node', @@ -305,7 +317,7 @@ export function parseStructuredHeaderDl( } } } - return { description, for: _for }; + return { description, for: _for, effects }; } export function formatPreamble( diff --git a/src/utils.ts b/src/utils.ts index e0d98df4..81fca391 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -219,3 +219,28 @@ export function attrValueLocation( return { line: attrLoc.line, column: attrLoc.col + (tagText.match(matcher)?.[0].length ?? 0) }; } } + +const KNOWN_EFFECTS = ['user-code']; +export function validateEffects(spec: Spec, effectsRaw: string[], node: Element) { + const effects = []; + const unknownEffects = []; + + for (const e of effectsRaw) { + if (KNOWN_EFFECTS.indexOf(e) !== -1) { + effects.push(e); + } else { + unknownEffects.push(e); + } + } + + if (unknownEffects.length !== 0) { + spec.warn({ + type: 'node', + ruleId: 'unknown-effects', + message: `unknown effects: ${unknownEffects}`, + node, + }); + } + + return effects; +} diff --git a/test/baselines/generated-reference/effect-user-code.html b/test/baselines/generated-reference/effect-user-code.html new file mode 100644 index 00000000..cb7df89c --- /dev/null +++ b/test/baselines/generated-reference/effect-user-code.html @@ -0,0 +1,75 @@ + +
+ + +

1 UserCode ( )

+

The abstract operation UserCode takes no arguments. It performs the following steps when called:

+
  1. Call user code.
+
+ + +

2 Nop ( )

+

The abstract operation Nop takes no arguments. It performs the following steps when called:

+
  1. Do nothing.
+
+ + +

3 DirectCall()

+

The abstract operation DirectCall takes no arguments. Calling AOs that can call user code should insert e-user-code as a class into the AO link. It performs the following steps when called:

+
  1. UserCode().
+
+ + +

4 TransitiveCall()

+

The abstract operation TransitiveCall takes no arguments. Calling AOs that can transitively call user code should insert e-user-code as a class into the AO link. It performs the following steps when called:

+
  1. DirectCall().
+
+ + +

5 SuppressedDirectCall()

+

The abstract operation SuppressedDirectCall takes no arguments. Can-call-user-code callsites that are suppressed do not get e-user-code as a class in the AO link. It performs the following steps when called:

+
  1. TransitiveCall().
+
+ + +

6 SuppressedTransitiveCall()

+

The abstract operation SuppressedTransitiveCall takes no arguments. Can-call-user-code callsites that are suppressed do not propagate the effect It performs the following steps when called:

+
  1. SuppressedDirectCall().
+
+ + +

7 AddedDirectCall()

+

The abstract operation AddedDirectCall takes no arguments. AOs can have manually added user-code effect at a callsite that propagates. It performs the following steps when called:

+
  1. Nop().
+
+ + +

8 AddedTransitiveCall()

+

The abstract operation AddedTransitiveCall takes no arguments. AOs can have manually added user-code effect at a callsite that propagates. It performs the following steps when called:

+
  1. AddedDirectCall().
+
+ + +

9 SDOInvocations()

+

The abstract operation SDOInvocations takes no arguments. SDO-style invocations of AOs that can call user code also have the e-user-code class in the link. It performs the following steps when called:

+
  1. UserCode of Bar.
+
+ + +

10 NonInvocations()

+

The abstract operation NonInvocations takes no arguments. Non-invocations (i.e. not followed by () or " of") do not have e-user-code as a class in the AO link. It performs the following steps when called:

+
  1. UserCode is an abstract operation.
+
+ + +

11 Static Semantics: StaticDirectCall

+

The syntax-directed operation StaticDirectCall takes no arguments. Static semantics suppress user-code.

+
  1. UserCode().
+
+ + +

12 Static Semantics: StaticTransitiveCall

+

The syntax-directed operation StaticTransitiveCall takes no arguments. Static semantics suppress user-code.

+
  1. StaticDirectCall of Baz.
+
+
\ No newline at end of file diff --git a/test/baselines/generated-reference/multipage.html/multipage/index.html b/test/baselines/generated-reference/multipage.html/multipage/index.html index d19f787a..0a100d38 100644 --- a/test/baselines/generated-reference/multipage.html/multipage/index.html +++ b/test/baselines/generated-reference/multipage.html/multipage/index.html @@ -1,7 +1,7 @@ - +