Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix imported feature detection #411

Merged
merged 2 commits into from
Dec 22, 2023
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
73 changes: 67 additions & 6 deletions src/javascript/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,88 @@ import type {CallExpression, Identifier, Literal, Node, TemplateLiteral} from "a
import {simple} from "acorn-walk";
import {getLocalPath} from "../files.js";
import type {Feature} from "../javascript.js";
import {defaultGlobals} from "./globals.js";
import {findReferences} from "./references.js";
import {syntaxError} from "./syntaxError.js";

export function findFeatures(node: Node, path: string, references: Identifier[], input: string): Feature[] {
const featureMap = getFeatureReferenceMap(node);
const features: Feature[] = [];

simple(node, {
CallExpression(node) {
const {callee} = node;
// Ignore function calls that are not references to the feature. For
// example, if there’s a local variable called Secret, that will mask the
// built-in Secret and won’t be considered a feature.
if (callee.type !== "Identifier" || !references.includes(callee)) return;
const {name: type} = callee;
if (type !== "Secret" && type !== "FileAttachment" && type !== "DatabaseClient") return;
if (callee.type !== "Identifier") return;
let type = featureMap.get(callee);
// If this feature wasn’t explicitly imported into this cell, then ignore
// function calls that are not references to the feature. For example, if
// there’s a local variable called Secret, that will mask the built-in
// Secret and won’t be considered a feature.
if (!type) {
if (!references.includes(callee)) return;
const name = callee.name;
if (name !== "Secret" && name !== "FileAttachment" && name !== "DatabaseClient") return;
type = name;
}
features.push(getFeature(type, node, path, input));
}
});

return features;
}

/**
* Returns a map from Identifier to the feature type, such as FileAttachment.
* Note that this may be different than the identifier.name because of aliasing.
*/
export function getFeatureReferenceMap(node: Node): Map<Identifier, Feature["type"]> {
const declarations = new Set<{name: string}>();
const alias = new Map<string, Feature["type"]>();
let globals: Set<string> | undefined;

// Find the declared local names of the imported symbol. Only named imports
// are supported. TODO Support namespace imports?
simple(node, {
ImportDeclaration(node) {
if (node.source.value === "npm:@observablehq/stdlib") {
for (const specifier of node.specifiers) {
if (
specifier.type === "ImportSpecifier" &&
specifier.imported.type === "Identifier" &&
(specifier.imported.name === "FileAttachment" ||
specifier.imported.name === "Secret" ||
specifier.imported.name === "DatabaseClient")
) {
declarations.add(specifier.local);
alias.set(specifier.local.name, specifier.imported.name);
}
}
}
}
});

// If the import is masking a global, don’t treat it as a global (since we’ll
// ignore the import declaration below).
for (const name of alias.keys()) {
if (defaultGlobals.has(name)) {
if (globals === undefined) globals = new Set(defaultGlobals);
globals.delete(name);
}
}

function filterDeclaration(node: {name: string}): boolean {
return !declarations.has(node); // treat the imported declaration as unbound
}

const references = findReferences(node, {globals, filterDeclaration});
const map = new Map<Identifier, Feature["type"]>();
for (const r of references) {
const type = alias.get(r.name);
if (type) map.set(r, type);
}
return map;
}

export function getFeature(type: Feature["type"], node: CallExpression, path: string, input: string): Feature {
const {
arguments: args,
Expand Down
56 changes: 1 addition & 55 deletions src/javascript/imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,7 @@ import {type Feature, type ImportReference, type JavaScriptNode} from "../javasc
import {parseOptions} from "../javascript.js";
import {Sourcemap} from "../sourcemap.js";
import {relativeUrl, resolvePath} from "../url.js";
import {getFeature, getStringLiteralValue, isStringLiteral} from "./features.js";
import {defaultGlobals} from "./globals.js";
import {findReferences} from "./references.js";
import {getFeature, getFeatureReferenceMap, getStringLiteralValue, isStringLiteral} from "./features.js";

type ImportNode = ImportDeclaration | ImportExpression;
type ExportNode = ExportAllDeclaration | ExportNamedDeclaration;
Expand Down Expand Up @@ -132,58 +130,6 @@ export function parseLocalImports(root: string, paths: string[]): ImportsAndFeat
return {imports, features};
}

/**
* Returns a map from Identifier to the feature type, such as FileAttachment.
* Note that this may be different than the identifier.name because of aliasing.
*/
export function getFeatureReferenceMap(node: Node): Map<Identifier, Feature["type"]> {
Copy link
Member Author

@mbostock mbostock Dec 20, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved this into features.ts to avoid a circular import. I didn’t make other changes.

const declarations = new Set<{name: string}>();
const alias = new Map<string, Feature["type"]>();
let globals: Set<string> | undefined;

// Find the declared local names of the imported symbol. Only named imports
// are supported. TODO Support namespace imports?
simple(node, {
ImportDeclaration(node) {
if (node.source.value === "npm:@observablehq/stdlib") {
for (const specifier of node.specifiers) {
if (
specifier.type === "ImportSpecifier" &&
specifier.imported.type === "Identifier" &&
(specifier.imported.name === "FileAttachment" ||
specifier.imported.name === "Secret" ||
specifier.imported.name === "DatabaseClient")
) {
declarations.add(specifier.local);
alias.set(specifier.local.name, specifier.imported.name);
}
}
}
}
});

// If the import is masking a global, don’t treat it as a global (since we’ll
// ignore the import declaration below).
for (const name of alias.keys()) {
if (defaultGlobals.has(name)) {
if (globals === undefined) globals = new Set(defaultGlobals);
globals.delete(name);
}
}

function filterDeclaration(node: {name: string}): boolean {
return !declarations.has(node); // treat the imported declaration as unbound
}

const references = findReferences(node, {globals, filterDeclaration});
const map = new Map<Identifier, Feature["type"]>();
for (const r of references) {
const type = alias.get(r.name);
if (type) map.set(r, type);
}
return map;
}

export function findImportFeatures(node: Node, path: string, input: string): Feature[] {
const featureMap = getFeatureReferenceMap(node);
const features: Feature[] = [];
Expand Down
34 changes: 34 additions & 0 deletions test/javascript/features-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import assert from "node:assert";
import type {Program} from "acorn";
import {Parser} from "acorn";
import {findFeatures} from "../../src/javascript/features.js";
import {findReferences} from "../../src/javascript/references.js";

describe("findFeatures(node, path, references, input)", () => {
it("finds FileAttachment", () => {
assert.deepStrictEqual(features('FileAttachment("foo.json")'), [{type: "FileAttachment", name: "foo.json"}]);
});
it("finds Secret", () => {
assert.deepStrictEqual(features('Secret("foo")'), [{type: "Secret", name: "foo"}]);
});
it("finds DatabaseClient", () => {
assert.deepStrictEqual(features('DatabaseClient("foo")'), [{type: "DatabaseClient", name: "foo"}]);
});
it("finds imported FileAttachment", () => {
assert.deepStrictEqual(features('import {FileAttachment} from "npm:@observablehq/stdlib";\nFileAttachment("foo.json")'), [{type: "FileAttachment", name: "foo.json"}]); // prettier-ignore
assert.deepStrictEqual(features('import {FileAttachment as F} from "npm:@observablehq/stdlib";\nF("foo.json")'), [{type: "FileAttachment", name: "foo.json"}]); // prettier-ignore
});
it("finds imported DatabaseClient", () => {
assert.deepStrictEqual(features('import {DatabaseClient} from "npm:@observablehq/stdlib";\nDatabaseClient("foo")'), [{type: "DatabaseClient", name: "foo"}]); // prettier-ignore
assert.deepStrictEqual(features('import {DatabaseClient as D} from "npm:@observablehq/stdlib";\nD("foo")'), [{type: "DatabaseClient", name: "foo"}]); // prettier-ignore
});
});

function features(input) {
const node = parse(input);
return findFeatures(node, "index.js", findReferences(node), input);
}

function parse(input: string): Program {
return Parser.parse(input, {ecmaVersion: 13, sourceType: "module"});
}
3 changes: 2 additions & 1 deletion test/javascript/imports-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import assert from "node:assert";
import type {Node, Program} from "acorn";
import {Parser} from "acorn";
import {ascending} from "d3-array";
import {getFeatureReferenceMap, parseLocalImports, rewriteModule} from "../../src/javascript/imports.js";
import {getFeatureReferenceMap} from "../../src/javascript/features.js";
import {parseLocalImports, rewriteModule} from "../../src/javascript/imports.js";
import type {Feature, ImportReference} from "../../src/javascript.js";

describe("parseLocalImports(root, paths)", () => {
Expand Down