Skip to content
Open
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
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./javascript/globals.js";
export * from "./javascript/parse.js";
export * from "./javascript/template.js";
export * from "./javascript/transpile.js";
Expand Down
6 changes: 2 additions & 4 deletions src/javascript/assignments.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import {assert, test} from "vitest";
import {parseJavaScript} from "./parse.js";
import {findReferences} from "./references.js";

function check(input: string) {
const cell = parseJavaScript(input);
findReferences(cell.body, {input});
function check(input: string): void {
parseJavaScript(input);
}

test("allows non-external assignments", () => {
Expand Down
5 changes: 2 additions & 3 deletions src/javascript/assignments.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type {Expression, Identifier, Node, Pattern, VariableDeclaration} from "acorn";
import {defaultGlobals} from "./globals.js";
import {syntaxError} from "./syntaxError.js";
import {ancestor} from "./walk.js";

Expand All @@ -11,12 +10,12 @@ export function checkAssignments(
input,
locals,
references,
globals = defaultGlobals
globals
}: {
input: string;
locals: Map<Node, Set<string>>;
globals?: Set<string>;
references: Identifier[];
globals: Set<string>;
}
): void {
function isLocal({name}: Identifier, parents: Node[]): boolean {
Expand Down
10 changes: 7 additions & 3 deletions src/javascript/files.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import type {Node} from "acorn";
import type {Identifier, Node} from "acorn";
import {findReferences} from "./references.js";
import {Sourcemap} from "./sourcemap.js";
import {simple} from "./walk.js";

function isFileAttachment({name}: Identifier): boolean {
return name === "FileAttachment";
}

export function rewriteFileExpressions(output: Sourcemap, body: Node): void {
const files = new Set(findReferences(body, {filterReference: ({name}) => name === "FileAttachment"}));
const {references} = findReferences(body, {filterReference: isFileAttachment});
simple(body, {
CallExpression(node) {
const {callee} = node;
if (callee.type !== "Identifier" || !files.has(callee)) return;
if (callee.type !== "Identifier" || !references.includes(callee)) return;
const args = node.arguments;
if (args.length === 0) return;
const [arg] = args;
Expand Down
27 changes: 21 additions & 6 deletions src/javascript/parse.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import {Parser, tokTypes} from "acorn";
import type {Expression, Identifier, Options, Program} from "acorn";
import {findReferences} from "./references.js";
import {findDeclarations} from "./declarations.js";
import {checkAssignments} from "./assignments.js";
import {findAwaits} from "./awaits.js";
import {findDeclarations} from "./declarations.js";
import {checkExports} from "./imports.js";
import {defaultGlobals} from "./globals.js";
import {findReferences} from "./references.js";

export const acornOptions: Options = {
ecmaVersion: "latest",
sourceType: "module"
};

export type ParseOptions = {
globals?: Set<string>;
};

// TODO files
export interface JavaScriptCell {
body: Program | Expression;
Expand All @@ -18,29 +25,37 @@ export interface JavaScriptCell {
async: boolean; // does this use top-level await?
}

export function maybeParseJavaScript(input: string): JavaScriptCell | undefined {
export function maybeParseJavaScript(input: string, options?: ParseOptions): JavaScriptCell | undefined {
try {
return parseJavaScript(input);
return parseJavaScript(input, options);
} catch (error) {
if (!(error instanceof SyntaxError)) throw error;
return;
}
}

export function parseJavaScript(input: string): JavaScriptCell {
export function parseJavaScript(input: string, options: ParseOptions = {}): JavaScriptCell {
let expression = maybeParseExpression(input); // first attempt to parse as expression
if (expression?.type === "ClassExpression" && expression.id) expression = null; // treat named class as program
if (expression?.type === "FunctionExpression" && expression.id) expression = null; // treat named function as program
const body = expression ?? parseProgram(input); // otherwise parse as a program
const {globals = defaultGlobals} = options;
const {locals, references} = findReferences(body, {filterReference: excludeNames(globals)});
checkAssignments(body, {locals, references, globals, input});
checkExports(body, {input});
return {
body,
declarations: expression ? null : findDeclarations(body as Program, input),
references: findReferences(body, {input}),
references,
expression: !!expression,
async: findAwaits(body).length > 0
};
}

function excludeNames(excludes: Set<string>): (identifier: Identifier) => boolean {
return (identifier: Identifier) => !excludes.has(identifier.name);
}

function parseProgram(input: string): Program {
return Parser.parse(input, acornOptions);
}
Expand Down
21 changes: 6 additions & 15 deletions src/javascript/references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@ import type {BlockStatement, CatchClause, Class} from "acorn";
import type {ForInStatement, ForOfStatement, ForStatement} from "acorn";
import type {FunctionDeclaration, FunctionExpression} from "acorn";
import type {Identifier, Node, Pattern, Program} from "acorn";
import {checkAssignments} from "./assignments.js";
import {defaultGlobals} from "./globals.js";
import {checkExports} from "./imports.js";
import {ancestor} from "./walk.js";

// Based on https://github.com/ForbesLindesay/acorn-globals
Expand Down Expand Up @@ -42,17 +39,16 @@ function isBlockScope(node: Node): node is FunctionNode | Program | BlockStateme
export function findReferences(
node: Node,
{
input,
globals = defaultGlobals,
filterReference = (identifier: Identifier) => !globals.has(identifier.name),
filterReference = () => true,
filterDeclaration = () => true
}: {
input?: string;
globals?: Set<string>;
filterReference?: (identifier: Identifier) => boolean;
filterDeclaration?: (identifier: {name: string}) => boolean;
} = {}
): Identifier[] {
): {
locals: Map<Node, Set<string>>;
references: Identifier[];
} {
const locals = new Map<Node, Set<string>>();
const references: Identifier[] = [];

Expand Down Expand Up @@ -163,10 +159,5 @@ export function findReferences(
Identifier: identifier
});

if (input !== undefined) {
checkAssignments(node, {locals, references, globals, input});
checkExports(node, {input});
}

return references;
return {locals, references};
}
5 changes: 3 additions & 2 deletions src/javascript/transpile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {rewriteFileExpressions} from "./files.js";
import {hasImportDeclaration} from "./imports.js";
import {rewriteImportDeclarations, rewriteImportExpressions} from "./imports.js";
import {transpileObservable} from "./observable.js";
import type {ParseOptions} from "./parse.js";
import {parseJavaScript} from "./parse.js";
import {Sourcemap} from "./sourcemap.js";
import {transpileTemplate} from "./template.js";
Expand All @@ -26,7 +27,7 @@ export type TranspiledJavaScript = {
automutable?: boolean;
};

export type TranspileOptions = {
export type TranspileOptions = ParseOptions & {
/** If true, resolve local imports paths relative to document.baseURI. */
resolveLocalImports?: boolean;
/** If true, resolve file using import.meta.url (so Vite treats it as an asset). */
Expand Down Expand Up @@ -77,7 +78,7 @@ export function transpileJavaScript(
input: string,
options?: TranspileOptions
): TranspiledJavaScript {
const cell = parseJavaScript(input);
const cell = parseJavaScript(input, options);
let async = cell.async;
const inputs = Array.from(new Set(cell.references.map((r) => r.name)));
if (hasImportDeclaration(cell.body)) async = true;
Expand Down
8 changes: 5 additions & 3 deletions src/runtime/define.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type {Module, Variable, VariableDefinition} from "@observablehq/runtime";
import type {Module, Variable, VariableDefinition, VariableOptions} from "@observablehq/runtime";
import type {DisplayState} from "./display.js";
import {clear, display, observe} from "./display.js";
import {input} from "./stdlib/generators/index.js";
Expand All @@ -14,6 +14,8 @@ export type Definition = {
id: number;
/** the cell’s definition function */
body: VariableDefinition;
/** any shadowed variables */
shadow?: VariableOptions["shadow"];
/** the names of this cell’s inputs (unbound references), if any */
inputs?: string[];
/** the names of this cell’s outputs (top-level declarations), if any */
Expand All @@ -31,9 +33,9 @@ export type Definition = {
};

export function define(main: Module, state: DefineState, definition: Definition, observer = observe): void {
const {id, body, inputs = [], outputs = [], output, autodisplay, autoview, automutable} = definition;
const {id, body, shadow = {}, inputs = [], outputs = [], output, autodisplay, autoview, automutable} = definition;
const variables = state.variables;
const v = main.variable(observer(state, definition), {shadow: {}});
const v = main.variable(observer(state, definition), {shadow});
const vid = output ?? (outputs.length ? `cell ${id}` : null);
state.autoclear = true;
if (inputs.includes("display") || inputs.includes("view")) {
Expand Down