diff --git a/package.json b/package.json index 2cc623c9..4f50132a 100644 --- a/package.json +++ b/package.json @@ -38,9 +38,9 @@ "scripts": { "test": "node tests/index.js", "build": "rescript", - "check:features": "node scripts/check-features.mjs", - "format": "rescript format && oxfmt ./tests/index.js ./scripts/check-features.mjs ./package.json ./docs && prettier --write ./docs/pages", - "format:check": "rescript format --check && oxfmt ./tests/index.js ./scripts/check-features.mjs ./package.json ./docs --check && prettier --check ./docs/pages", + "check:features": "node --test scripts/check-features.test.mjs && node scripts/check-features.mjs", + "format": "rescript format && oxfmt ./tests/index.js ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs && prettier --write ./docs/pages", + "format:check": "rescript format --check && oxfmt ./tests/index.js ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./package.json ./docs --check && prettier --check ./docs/pages", "docs": "astro dev", "prebuild:docs": "node docs/llm.js", "build:docs": "astro build" diff --git a/scripts/check-features.mjs b/scripts/check-features.mjs index 427d6b7b..df400647 100644 --- a/scripts/check-features.mjs +++ b/scripts/check-features.mjs @@ -3,7 +3,8 @@ import path from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const scriptPath = fileURLToPath(import.meta.url); +const repoRoot = path.resolve(path.dirname(scriptPath), ".."); const configPath = path.join(repoRoot, "rescript.json"); const expectedFeatureOwners = new Map([ @@ -43,6 +44,57 @@ const uniqueDuplicates = (values) => [ const sameMembers = (left, right) => left.length === right.length && left.every((value) => right.includes(value)); +const publicModulesFrom = (sourceEntries) => + sourceEntries.flatMap((source) => + (source.public ?? []).map((moduleName) => ({ moduleName, sourceDir: source.dir })), + ); + +const readSource = (filePath) => { + try { + return { _tag: "Success", value: readFileSync(filePath, "utf8") }; + } catch (error) { + return { + _tag: "Failure", + message: error instanceof Error ? error.message : String(error), + }; + } +}; + +// Only direct `Foo.t = BackingModule.backingType` aliases, with or without type parameters, +// expose an internal type whose editor completion owner needs validation. +export const parsePublicTypeAlias = (source) => { + const match = + /^type t(?:<[^>\r\n]+>)?[ \t]*=[ \t]*([A-Z][A-Za-z0-9_]*)\.([A-Za-z][A-Za-z0-9_]*)\b/m.exec( + source, + ); + return match === null ? null : { backingModule: match[1], backingType: match[2] }; +}; + +// Public-to-public `Foo.t = Bar.t` re-exports keep Bar as the completion owner. Requiring +// the backing type to point at both public modules would make these valid aliases conflict. +const isPublicToPublicAlias = (alias, publicModuleNames) => + alias.backingType === "t" && publicModuleNames.has(alias.backingModule); + +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +// Match the annotation attached to the backing declaration so an unrelated annotation +// elsewhere in the source file cannot satisfy the check. +const completionOwnerFor = (source, backingType) => { + const escapedType = escapeRegExp(backingType); + const attributes = String.raw`(?:[ \t]+@[A-Za-z][A-Za-z0-9_.]*(?:\([^\r\n)]*\))?)*`; + const declaration = String.raw`(?:type(?:[ \t]+rec)?|and)[ \t]+${escapedType}\b`; + const pattern = new RegExp( + String.raw`@editor\.completeFrom\(([^)]+)\)${attributes}[ \t]*(?:\r?\n[ \t]*)?${declaration}`, + "m", + ); + return pattern.exec(source)?.[1] ?? null; +}; + +const modulePathFor = (sourceEntries, moduleName) => + sourceEntries + .map((source) => path.join(repoRoot, source.dir, `${moduleName}.res`)) + .find(existsSync); + const readConfig = () => { try { return { _tag: "Success", value: JSON.parse(readFileSync(configPath, "utf8")) }; @@ -110,9 +162,7 @@ const validateFeatureOwners = (featureEntries, sourceEntries) => { }; const validatePublicModules = (sourceEntries) => { - const publicModules = sourceEntries.flatMap((source) => - (source.public ?? []).map((moduleName) => ({ moduleName, sourceDir: source.dir })), - ); + const publicModules = publicModulesFrom(sourceEntries); const duplicateModules = uniqueDuplicates(publicModules.map(({ moduleName }) => moduleName)); const missingModules = publicModules .filter( @@ -131,6 +181,48 @@ const validatePublicModules = (sourceEntries) => { ]; }; +// Internal backing types must complete from their public module. This keeps editor suggestions +// on `Foo` instead of leaking implementation modules such as DOMTree or *Types modules. +const validateCompletionAlias = (publicModule, sourceEntries, publicModuleNames) => { + const publicPath = path.join(repoRoot, publicModule.sourceDir, `${publicModule.moduleName}.res`); + const publicSource = readSource(publicPath); + if (publicSource._tag === "Failure") { + return [`Unable to read ${publicPath}: ${publicSource.message}`]; + } + + const alias = parsePublicTypeAlias(publicSource.value); + if (alias === null || isPublicToPublicAlias(alias, publicModuleNames)) { + return []; + } + + const backingPath = modulePathFor(sourceEntries, alias.backingModule); + if (backingPath === undefined) { + return [ + `Unable to resolve backing module ${alias.backingModule} for ${publicModule.moduleName}.t.`, + ]; + } + + const backingSource = readSource(backingPath); + if (backingSource._tag === "Failure") { + return [`Unable to read ${backingPath}: ${backingSource.message}`]; + } + + const actualOwner = completionOwnerFor(backingSource.value, alias.backingType); + return actualOwner === publicModule.moduleName + ? [] + : [ + `${alias.backingModule}.${alias.backingType}, aliased by ${publicModule.moduleName}.t, must use @editor.completeFrom(${publicModule.moduleName}); received ${actualOwner ?? "no annotation"}.`, + ]; +}; + +const validateCompletionAliases = (sourceEntries) => { + const publicModules = publicModulesFrom(sourceEntries); + const publicModuleNames = new Set(publicModules.map(({ moduleName }) => moduleName)); + return publicModules.flatMap((publicModule) => + validateCompletionAlias(publicModule, sourceEntries, publicModuleNames), + ); +}; + const validateConfig = (config) => { const featureEntries = Object.entries(config.features ?? {}); const sourceEntries = (config.sources ?? []).filter( @@ -142,6 +234,7 @@ const validateConfig = (config) => { ...validateSources(sourceEntries), ...validateFeatureOwners(featureEntries, sourceEntries), ...validatePublicModules(sourceEntries), + ...validateCompletionAliases(sourceEntries), ]; }; @@ -170,25 +263,27 @@ const compileFeature = (featureName) => { : { _tag: "Failure", message: formatProcessFailure(featureName, "build", buildResult) }; }; -const configResult = readConfig(); -if (configResult._tag === "Failure") { - console.error(`Unable to read rescript.json: ${configResult.message}`); - process.exit(1); -} +if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === scriptPath) { + const configResult = readConfig(); + if (configResult._tag === "Failure") { + console.error(`Unable to read rescript.json: ${configResult.message}`); + process.exit(1); + } -const validationErrors = validateConfig(configResult.value); -if (validationErrors.length > 0) { - console.error(validationErrors.join("\n\n")); - process.exit(1); -} + const validationErrors = validateConfig(configResult.value); + if (validationErrors.length > 0) { + console.error(validationErrors.join("\n\n")); + process.exit(1); + } -console.log(`Validated ${expectedFeatureOwners.size} public feature definitions.`); + console.log(`Validated ${expectedFeatureOwners.size} public feature definitions.`); -for (const featureName of expectedFeatureOwners.keys()) { - const result = compileFeature(featureName); - if (result._tag === "Failure") { - console.error(result.message); - process.exit(1); + for (const featureName of expectedFeatureOwners.keys()) { + const result = compileFeature(featureName); + if (result._tag === "Failure") { + console.error(result.message); + process.exit(1); + } + console.log(`[ok] ${featureName}`); } - console.log(`[ok] ${featureName}`); } diff --git a/scripts/check-features.test.mjs b/scripts/check-features.test.mjs new file mode 100644 index 00000000..71dfd088 --- /dev/null +++ b/scripts/check-features.test.mjs @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parsePublicTypeAlias } from "./check-features.mjs"; + +test("parses a public type alias without type parameters", () => { + assert.deepEqual(parsePublicTypeAlias("type t = DOMTree.element"), { + backingModule: "DOMTree", + backingType: "element", + }); +}); + +test("parses a public type alias with type parameters", () => { + assert.deepEqual(parsePublicTypeAlias("type t<'r> = FileTypes.readableStream<'r>"), { + backingModule: "FileTypes", + backingType: "readableStream", + }); +}); diff --git a/src/core/DOM.res b/src/core/DOM.res index 61e7f60e..22337334 100644 --- a/src/core/DOM.res +++ b/src/core/DOM.res @@ -10,6 +10,7 @@ type document = private {} An abnormal event (called an exception) which occurs as a result of calling a method or accessing a property of a web API. [See DOMException on MDN](https://developer.mozilla.org/docs/Web/API/DOMException) */ +@editor.completeFrom(DOMException) type domException = { /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/DOMException/name) @@ -25,6 +26,7 @@ type domException = { A type returned by some APIs which contains a list of DOMString (strings). [See DOMStringList on MDN](https://developer.mozilla.org/docs/Web/API/DOMStringList) */ +@editor.completeFrom(DOMStringList) type domStringList = { /** Returns the number of strings in strings. diff --git a/src/device/WebMidiTypes.res b/src/device/WebMidiTypes.res index fa4af480..3c2eab56 100644 --- a/src/device/WebMidiTypes.res +++ b/src/device/WebMidiTypes.res @@ -13,6 +13,7 @@ type midiOutputMap = {} /** [See MIDIAccess on MDN](https://developer.mozilla.org/docs/Web/API/MIDIAccess) */ +@editor.completeFrom(WebMIDI) type midiAccess = { ...DOM.eventTarget, /** diff --git a/src/dom-nodes/DOMTree.res b/src/dom-nodes/DOMTree.res index 610dee48..cae90ab6 100644 --- a/src/dom-nodes/DOMTree.res +++ b/src/dom-nodes/DOMTree.res @@ -1302,7 +1302,7 @@ TODO: mark as private once mutating fields of private records is allowed length: int, } -@editor.completeFrom(htmlFormControlsCollection) +@editor.completeFrom(HTMLFormControlsCollection) and htmlFormControlsCollection = { length: int, } @@ -1602,6 +1602,7 @@ and htmlSlotElement = { mutable name: string, } +@editor.completeFrom(ElementInternals) and elementInternals = { shadowRoot: Null.t, form: Null.t, diff --git a/src/dom-nodes/Element.res b/src/dom-nodes/Element.res index 0694ff20..3a77251a 100644 --- a/src/dom-nodes/Element.res +++ b/src/dom-nodes/Element.res @@ -475,7 +475,7 @@ Sets the value of element's attribute whose namespace is namespace and local nam */ @send external setAttributeNS: ( - DOMTree.element, + T.t, ~namespace: string, ~qualifiedName: string, ~value: string, diff --git a/src/file/FileTypes.res b/src/file/FileTypes.res index c1833496..9ac49514 100644 --- a/src/file/FileTypes.res +++ b/src/file/FileTypes.res @@ -36,6 +36,7 @@ type blob = DOM.blob = private { This Streams API interface represents a readable stream of byte data. The WebApiFetch API offers a concrete instance of a ReadableStream through the body property of a Response object. [See ReadableStream on MDN](https://developer.mozilla.org/docs/Web/API/ReadableStream) */ +@editor.completeFrom(ReadableStream) type readableStream<'r> = { /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/ReadableStream/locked) @@ -70,7 +71,7 @@ type writableStreamDefaultController = private { Provides information about files and allows JavaScript in a web page to access their content. [See WebApiFile on MDN](https://developer.mozilla.org/docs/Web/API/File) */ -@editor.completeFrom(WebApiFile) +@editor.completeFrom(File) type file = DOM.file = private { ...blob, /** diff --git a/src/media/MediaSessionTypes.res b/src/media/MediaSessionTypes.res index dd9c7f19..33609b89 100644 --- a/src/media/MediaSessionTypes.res +++ b/src/media/MediaSessionTypes.res @@ -50,7 +50,7 @@ type mediaMetadata = { [See WebApiMediaSession on MDN](https://developer.mozilla.org/docs/Web/API/MediaSession) TODO: mark as private once mutating fields of private records is allowed */ -@editor.completeFrom(WebApiMediaSession) +@editor.completeFrom(MediaSession) type mediaSession = { /** [Read more on MDN](https://developer.mozilla.org/docs/Web/API/MediaSession/metadata) diff --git a/src/media/PictureInPictureTypes.res b/src/media/PictureInPictureTypes.res index 3b3138da..2b74c630 100644 --- a/src/media/PictureInPictureTypes.res +++ b/src/media/PictureInPictureTypes.res @@ -3,6 +3,7 @@ /** [See PictureInPictureWindow on MDN](https://developer.mozilla.org/docs/Web/API/PictureInPictureWindow) */ +@editor.completeFrom(PictureInPicture) type pictureInPictureWindow = { ...DOM.eventTarget, /** diff --git a/src/media/RemotePlaybackTypes.res b/src/media/RemotePlaybackTypes.res index c850d3d8..fc2528b9 100644 --- a/src/media/RemotePlaybackTypes.res +++ b/src/media/RemotePlaybackTypes.res @@ -8,7 +8,7 @@ type remotePlaybackState = /** [See WebApiRemotePlayback on MDN](https://developer.mozilla.org/docs/Web/API/RemotePlayback) */ -@editor.completeFrom(WebApiRemotePlayback) +@editor.completeFrom(RemotePlayback) type remotePlayback = private { ...DOM.eventTarget, /** diff --git a/src/messaging/NotificationTypes.res b/src/messaging/NotificationTypes.res index 8e5c32a1..6cb02b6c 100644 --- a/src/messaging/NotificationTypes.res +++ b/src/messaging/NotificationTypes.res @@ -14,7 +14,7 @@ type notificationPermission = This Notifications API interface is used to configure and display desktop notifications to the user. [See WebApiNotification on MDN](https://developer.mozilla.org/docs/Web/API/Notification) */ -@editor.completeFrom(WebApiNotification) +@editor.completeFrom(Notification) type notification = private { ...DOM.eventTarget, /** diff --git a/src/window/VisualViewportTypes.res b/src/window/VisualViewportTypes.res index 24e00867..5a1c35ff 100644 --- a/src/window/VisualViewportTypes.res +++ b/src/window/VisualViewportTypes.res @@ -3,6 +3,7 @@ /** [See WebApiVisualViewport on MDN](https://developer.mozilla.org/docs/Web/API/VisualViewport) */ +@editor.completeFrom(VisualViewport) type visualViewport = { ...DOM.eventTarget, /** diff --git a/src/workers/ServiceWorkerTypes.res b/src/workers/ServiceWorkerTypes.res index 24561355..1f16d684 100644 --- a/src/workers/ServiceWorkerTypes.res +++ b/src/workers/ServiceWorkerTypes.res @@ -21,7 +21,7 @@ type workerType = This WebApiServiceWorker API interface provides a reference to a service worker. Multiple browsing contexts (e.g. pages, workers, etc.) can be associated with the same service worker, each through a unique WebApiServiceWorker object. [See WebApiServiceWorker on MDN](https://developer.mozilla.org/docs/Web/API/ServiceWorker) */ -@editor.completeFrom(WebApiServiceWorker) +@editor.completeFrom(ServiceWorker) type serviceWorker = private { ...DOM.eventTarget, /** @@ -118,7 +118,7 @@ type clients The ServiceWorkerGlobalScope interface of the Service Worker API represents the global execution context of a service worker. [See ServiceWorkerGlobalScope on MDN](https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope) */ -@editor.completeFrom(ServiceWorkerGlobalScope) +@editor.completeFrom(ServiceWorkerScope) type serviceWorkerGlobalScope = private { ...WebWorkersTypes.workerGlobalScope, /** diff --git a/src/workers/WebWorkersTypes.res b/src/workers/WebWorkersTypes.res index def18f39..2ddccb71 100644 --- a/src/workers/WebWorkersTypes.res +++ b/src/workers/WebWorkersTypes.res @@ -1,4 +1,4 @@ -type sharedWorker +@editor.completeFrom(SharedWorker) type sharedWorker /** The WorkerGlobalScope interface of the Web Workers API is an interface representing the scope of any worker. @@ -39,7 +39,7 @@ namespaces objects, and constructors, not typically associated with the worker global scope, but available on it, are listed in the JavaScript Reference. See the complete list of functions available to workers. */ -@editor.completeFrom(SharedWorkerGlobalScope) +@editor.completeFrom(SharedWorkerScope) type sharedWorkerGlobalScope = private { ...workerGlobalScope, name: option, diff --git a/tests/DOMAPI/HTMLElement__test.res b/tests/DOMAPI/HTMLElement__test.res index d31e2c24..dee322fc 100644 --- a/tests/DOMAPI/HTMLElement__test.res +++ b/tests/DOMAPI/HTMLElement__test.res @@ -4,3 +4,14 @@ DomGlobal.document ->Option.forEach(form => { form->Element.scrollIntoViewWithOptions({behavior: DOM.Smooth}) }) + +let asNode = (element: Element.t): Node.t => element->Element.asNode + +let asElement = (element: HTMLElement.t): Element.t => element->HTMLElement.asElement + +let setNamespacedAttribute = (element: HTMLElement.t) => + element->HTMLElement.setAttributeNS( + ~namespace="http://www.w3.org/1999/xhtml", + ~qualifiedName="data-follow-up", + ~value="complete", + )