Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
137 changes: 116 additions & 21 deletions scripts/check-features.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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")) };
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -142,6 +234,7 @@ const validateConfig = (config) => {
...validateSources(sourceEntries),
...validateFeatureOwners(featureEntries, sourceEntries),
...validatePublicModules(sourceEntries),
...validateCompletionAliases(sourceEntries),
];
};

Expand Down Expand Up @@ -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}`);
}
18 changes: 18 additions & 0 deletions scripts/check-features.test.mjs
Original file line number Diff line number Diff line change
@@ -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",
});
});
2 changes: 2 additions & 0 deletions src/core/DOM.res
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/device/WebMidiTypes.res
Original file line number Diff line number Diff line change
Expand Up @@ -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,
/**
Expand Down
3 changes: 2 additions & 1 deletion src/dom-nodes/DOMTree.res
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -1602,6 +1602,7 @@ and htmlSlotElement = {
mutable name: string,
}

@editor.completeFrom(ElementInternals)
and elementInternals = {
shadowRoot: Null.t<shadowRoot>,
form: Null.t<htmlFormElement>,
Expand Down
2 changes: 1 addition & 1 deletion src/dom-nodes/Element.res
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/file/FileTypes.res
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
/**
Expand Down
2 changes: 1 addition & 1 deletion src/media/MediaSessionTypes.res
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/media/PictureInPictureTypes.res
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
/**
[See PictureInPictureWindow on MDN](https://developer.mozilla.org/docs/Web/API/PictureInPictureWindow)
*/
@editor.completeFrom(PictureInPicture)
type pictureInPictureWindow = {
...DOM.eventTarget,
/**
Expand Down
2 changes: 1 addition & 1 deletion src/media/RemotePlaybackTypes.res
Original file line number Diff line number Diff line change
Expand Up @@ -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,
/**
Expand Down
2 changes: 1 addition & 1 deletion src/messaging/NotificationTypes.res
Original file line number Diff line number Diff line change
Expand Up @@ -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,
/**
Expand Down
1 change: 1 addition & 0 deletions src/window/VisualViewportTypes.res
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
/**
[See WebApiVisualViewport on MDN](https://developer.mozilla.org/docs/Web/API/VisualViewport)
*/
@editor.completeFrom(VisualViewport)
type visualViewport = {
...DOM.eventTarget,
/**
Expand Down
4 changes: 2 additions & 2 deletions src/workers/ServiceWorkerTypes.res
Original file line number Diff line number Diff line change
Expand Up @@ -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,
/**
Expand Down Expand Up @@ -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,
/**
Expand Down
4 changes: 2 additions & 2 deletions src/workers/WebWorkersTypes.res
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<string>,
Expand Down
11 changes: 11 additions & 0 deletions tests/DOMAPI/HTMLElement__test.res
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)