Skip to content

Commit

Permalink
feature: use vscode.workspace.fs API to load Expo projects (#252)
Browse files Browse the repository at this point in the history
* test: add tests for expo project and project cache

* refactor: use `vscode.workspace.fs` API to load Expo projects

* refactor: implement new `vscode.workspace.fs` API to load Expo projects

* fix: use `path.posix.join` in `relativeUri` for Windows

* refactor: drop `relativeUri` in favor of `vscode.Uri.joinPath`
  • Loading branch information
byCedric authored Mar 8, 2024
1 parent 2d0efa6 commit 5a596d1
Show file tree
Hide file tree
Showing 10 changed files with 142 additions and 57 deletions.
62 changes: 62 additions & 0 deletions src/expo/__tests__/project.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { expect } from 'chai';
import { findNodeAtLocation } from 'jsonc-parser';
import vscode from 'vscode';

import { readWorkspaceFile } from '../../utils/file';
import { ExpoProjectCache, findProjectFromWorkspaces } from '../project';

describe('ExpoProjectCache', () => {
it('adds disposable to extension context', () => {
const subscriptions: any[] = [];
using _projects = stubProjectCache(subscriptions);

expect(subscriptions).to.have.length(1);
});
});

describe('findProjectFromWorkspaces', () => {
it('returns projct from workspace using relative path', () => {
using projects = stubProjectCache();
const project = findProjectFromWorkspaces(projects, './manifest');

expect(project).to.exist;
});

it('returned project contains parsed package file', async () => {
using projects = stubProjectCache();
const project = await findProjectFromWorkspaces(projects, './manifest');

expect(project?.package.tree).to.exist;
expect(findNodeAtLocation(project!.package.tree, ['name'])!.value).to.equal('manifest');
});

it('returned project contains parsed expo manifest file', async () => {
using projects = stubProjectCache();
const project = await findProjectFromWorkspaces(projects, './manifest');

expect(project?.manifest!.tree).to.exist;
expect(findNodeAtLocation(project!.manifest!.tree, ['name'])!.value).to.equal('manifest');
});
});

describe('ExpoProject', () => {
it('returns expo version from package file', async () => {
using projects = stubProjectCache();

const project = await findProjectFromWorkspaces(projects, './manifest');
const workspace = vscode.workspace.workspaceFolders![0];
const packageUri = vscode.Uri.joinPath(workspace.uri, 'manifest', 'package.json');
const packageFile = JSON.parse(await readWorkspaceFile(packageUri));

expect(project?.expoVersion).to.equal(packageFile.dependencies.expo);
});
});

function stubProjectCache(subscriptions: vscode.ExtensionContext['subscriptions'] = []) {
const stubProjectCache = new ExpoProjectCache({ subscriptions });

// @ts-expect-error
stubProjectCache[Symbol.dispose] = () => stubProjectCache.dispose();

return stubProjectCache as Disposable & typeof stubProjectCache;
}
2 changes: 1 addition & 1 deletion src/expo/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,5 @@ export function resolveInstalledPluginInfo(
if (search) dependencies = dependencies.filter((name) => name.startsWith(search));
if (maxResults !== null) dependencies = dependencies.slice(0, maxResults);

return dependencies.map((name) => resolvePluginInfo(project.root, name)).filter(truthy);
return dependencies.map((name) => resolvePluginInfo(project.root.fsPath, name)).filter(truthy);
}
72 changes: 40 additions & 32 deletions src/expo/project.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import findUp from 'find-up';
import fs from 'fs';
import * as jsonc from 'jsonc-parser';
import path from 'path';
import vscode from 'vscode';

import { MapCacheProvider } from '../utils/cache';
import { debug } from '../utils/debug';
import { readWorkspaceFile } from '../utils/file';

const log = debug.extend('project');

Expand All @@ -22,11 +22,11 @@ export function getProjectRoot(filePath: string) {
* Try to get the project root from any of the current workspaces.
* This will iterate and try to detect an Expo project for each open workspaces.
*/
export function findProjectFromWorkspaces(projects: ExpoProjectCache, relativePath?: string) {
export async function findProjectFromWorkspaces(projects: ExpoProjectCache, relativePath?: string) {
const workspaces = vscode.workspace.workspaceFolders ?? [];

for (const workspace of workspaces) {
const project = findProjectFromWorkspace(projects, workspace, relativePath);
const project = await findProjectFromWorkspace(projects, workspace, relativePath);
if (project) return project;
}

Expand All @@ -43,8 +43,8 @@ export function findProjectFromWorkspace(
relativePath?: string
) {
return relativePath
? projects.maybeFromRoot(path.join(workspace.uri.fsPath, relativePath))
: projects.maybeFromRoot(workspace.uri.fsPath);
? projects.fromRoot(vscode.Uri.joinPath(workspace.uri, relativePath))
: projects.fromRoot(workspace.uri);
}

/**
Expand All @@ -55,56 +55,59 @@ export function findProjectFromWorkspace(
* You can use `fromManifest` or `fromPackage` when providing document links or diagnostics.
*/
export class ExpoProjectCache extends MapCacheProvider<ExpoProject> {
fromRoot(root: string) {
if (!this.cache.has(root)) {
const packageFile = parseJsonFile(fs.readFileSync(path.join(root, 'package.json'), 'utf-8'));

if (packageFile) {
this.cache.set(root, new ExpoProject(root, packageFile));
}
}

return this.cache.get(root);
}

fromPackage(pkg: vscode.TextDocument) {
const project = this.fromRoot(path.dirname(pkg.fileName));
async fromPackage(pkg: vscode.TextDocument) {
const project = await this.fromRoot(vscode.Uri.file(path.dirname(pkg.fileName)));
project?.setPackage(pkg.getText());
return project;
}

fromManifest(manifest: vscode.TextDocument) {
async fromManifest(manifest: vscode.TextDocument) {
const root = getProjectRoot(manifest.fileName);
const project = root ? this.fromRoot(root) : undefined;
const project = root ? await this.fromRoot(vscode.Uri.file(root)) : undefined;
project?.setManifest(manifest.getText());
return project;
}

maybeFromRoot(root: string) {
if (this.cache.has(root)) {
return this.cache.get(root);
async fromRoot(projectPath: vscode.Uri) {
const cacheKey = projectPath.toString();
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey);
}

// Check if there is a `package.json` file
if (!fs.existsSync(path.join(root, 'package.json'))) {
const packagePath = vscode.Uri.joinPath(projectPath, 'package.json');

// Ensure the project has a `package.json` file
const packageInfo = await vscode.workspace.fs.stat(packagePath);
if (packageInfo.type !== vscode.FileType.File) {
return undefined;
}

// Check if that `package.json` file contains `"expo"` as dependency
const packageFile = parseJsonFile(fs.readFileSync(path.join(root, 'package.json'), 'utf-8'));
if (!packageFile?.content.includes('"expo"')) {
// Ensure the project has `expo` as dependency
const packageFile = parseJsonFile(await readWorkspaceFile(packagePath));
if (!packageFile || !jsonc.findNodeAtLocation(packageFile.tree, ['dependencies', 'expo'])) {
return undefined;
}

const project = new ExpoProject(root, packageFile);
this.cache.set(root, project);
const project = new ExpoProject(projectPath, packageFile);

// Load the `app.json` or `app.config.json` file, if available
for (const appFileName of ['app.json', 'app.config.json']) {
const filePath = vscode.Uri.joinPath(projectPath, appFileName);
const fileStat = await vscode.workspace.fs.stat(filePath);
if (fileStat.type === vscode.FileType.File) {
project.setManifest(await readWorkspaceFile(filePath));
break;
}
}

this.cache.set(cacheKey, project);
return project;
}
}

export class ExpoProject {
constructor(
public readonly root: string,
public readonly root: vscode.Uri,
private packageFile: JsonFile,
private manifestFile?: JsonFile
) {
Expand All @@ -119,6 +122,11 @@ export class ExpoProject {
return this.manifestFile;
}

get expoVersion() {
const version = jsonc.findNodeAtLocation(this.packageFile.tree, ['dependencies', 'expo']);
return version?.type === 'string' ? version.value : undefined;
}

setPackage(content: string) {
if (content === this.packageFile.content) {
return this.packageFile;
Expand Down
28 changes: 19 additions & 9 deletions src/expoDebuggers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import {
inferDevicePlatform,
fetchDevicesToInspectFromUnknownWorkflow,
} from './expo/bundler';
import { ExpoProjectCache, ExpoProject, findProjectFromWorkspaces } from './expo/project';
import {
ExpoProjectCache,
ExpoProject,
findProjectFromWorkspaces,
findProjectFromWorkspace,
} from './expo/project';
import { debug } from './utils/debug';
import { featureTelemetry } from './utils/telemetry';
import { version as extensionVersion } from '../package.json';
Expand Down Expand Up @@ -47,7 +52,7 @@ export class ExpoDebuggersProvider implements vscode.DebugConfigurationProvider
}

async onDebugCommand() {
let project = findProjectFromWorkspaces(this.projects);
let project = await findProjectFromWorkspaces(this.projects);
let projectRelativePath: string | undefined = '';

if (!project) {
Expand All @@ -63,7 +68,7 @@ export class ExpoDebuggersProvider implements vscode.DebugConfigurationProvider
return log('No relative project path entered, aborting...');
}

project = findProjectFromWorkspaces(this.projects, projectRelativePath);
project = await findProjectFromWorkspaces(this.projects, projectRelativePath);
}

if (!project) {
Expand All @@ -75,7 +80,7 @@ export class ExpoDebuggersProvider implements vscode.DebugConfigurationProvider
);
}

log('Resolved dynamic project configuration for:', project.root);
log('Resolved dynamic project configuration for:', project.root.fsPath);
featureTelemetry('command', DEBUG_COMMAND, {
path: projectRelativePath ? 'nested' : 'workspace',
});
Expand All @@ -84,12 +89,17 @@ export class ExpoDebuggersProvider implements vscode.DebugConfigurationProvider
type: DEBUG_TYPE,
request: 'attach',
name: 'Inspect Expo app',
projectRoot: project.root,
projectRoot: project.root.fsPath,
});
}

provideDebugConfigurations(workspace?: vscode.WorkspaceFolder, token?: vscode.CancellationToken) {
const project = findProjectFromWorkspaces(this.projects);
async provideDebugConfigurations(
workspace?: vscode.WorkspaceFolder,
token?: vscode.CancellationToken
) {
const project = workspace
? await findProjectFromWorkspace(this.projects, workspace)
: await findProjectFromWorkspaces(this.projects);

return [
{
Expand Down Expand Up @@ -155,7 +165,7 @@ export class ExpoDebuggersProvider implements vscode.DebugConfigurationProvider
config: ExpoDebugConfig,
token?: vscode.CancellationToken
) {
const project = this.projects.maybeFromRoot(config.projectRoot);
const project = await this.projects.fromRoot(vscode.Uri.file(config.projectRoot));

if (!project) {
featureTelemetry('debugger', `${DEBUG_TYPE}/aborted`, { reason: 'no-project' });
Expand Down Expand Up @@ -185,7 +195,7 @@ export class ExpoDebuggersProvider implements vscode.DebugConfigurationProvider
// Resolve the target device config to inspect
const { platform, ...deviceConfig } = await resolveDeviceConfig(config, project);

featureTelemetry('debugger', `${DEBUG_TYPE}`, { platform });
featureTelemetry('debugger', `${DEBUG_TYPE}`, { platform, expoVersion: project.expoVersion });

return { ...config, ...deviceConfig };
}
Expand Down
4 changes: 2 additions & 2 deletions src/manifestAssetCompletions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export class ManifestAssetCompletionsProvider extends ExpoCompletionsProvider {
) {
if (!this.isEnabled) return null;

const project = this.projects.fromManifest(document);
const project = await this.projects.fromManifest(document);
if (!project?.manifest) {
log('Could not resolve project from manifest "%s"', document.fileName);
return [];
Expand Down Expand Up @@ -75,7 +75,7 @@ export class ManifestAssetCompletionsProvider extends ExpoCompletionsProvider {
// Search entities within the user-provided directory
const positionDir = getDirectoryPath(positionValue) ?? '';
const entities = await withCancelToken(token, () =>
vscode.workspace.fs.readDirectory(vscode.Uri.file(path.join(project.root, positionDir)))
vscode.workspace.fs.readDirectory(vscode.Uri.joinPath(project.root, positionDir))
);

return entities
Expand Down
9 changes: 4 additions & 5 deletions src/manifestDiagnostics.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { findNodeAtLocation, Node } from 'jsonc-parser';
import path from 'path';
import vscode from 'vscode';

import { FileReference, getFileReferences, manifestPattern } from './expo/manifest';
Expand Down Expand Up @@ -42,7 +41,7 @@ export class ManifestDiagnosticsProvider extends ExpoDiagnosticsProvider {

if (!this.isEnabled) return issues;

const project = this.projects.fromManifest(document);
const project = await this.projects.fromManifest(document);
if (!project?.manifest) {
log('Could not resolve project from manifest "%s"', document.fileName);
return issues;
Expand Down Expand Up @@ -85,8 +84,8 @@ function diagnosePlugin(document: vscode.TextDocument, project: ExpoProject, plu
}

try {
resetModuleFrom(project.root, nameValue);
resolvePluginFunctionUnsafe(project.root, nameValue);
resetModuleFrom(project.root.fsPath, nameValue);
resolvePluginFunctionUnsafe(project.root.fsPath, nameValue);
} catch (error) {
const issue = new vscode.Diagnostic(
getDocumentRange(document, nameRange),
Expand Down Expand Up @@ -115,7 +114,7 @@ async function diagnoseAsset(
reference: FileReference
) {
try {
const uri = vscode.Uri.file(path.join(project.root, reference.filePath));
const uri = vscode.Uri.joinPath(project.root, reference.filePath);
const asset = await vscode.workspace.fs.stat(uri);

if (asset.type === vscode.FileType.Directory) {
Expand Down
8 changes: 4 additions & 4 deletions src/manifestLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ export class ManifestLinksProvider extends ExpoLinkProvider {
);
}

provideDocumentLinks(document: vscode.TextDocument, token: vscode.CancellationToken) {
async provideDocumentLinks(document: vscode.TextDocument, token: vscode.CancellationToken) {
const links: vscode.DocumentLink[] = [];

if (!this.isEnabled) return links;

const project = this.projects.fromManifest(document);
const project = await this.projects.fromManifest(document);
if (!project?.manifest) {
log('Could not resolve project from manifest "%s"', document.fileName);
return links;
Expand All @@ -45,7 +45,7 @@ export class ManifestLinksProvider extends ExpoLinkProvider {
if (token.isCancellationRequested) return links;

const { nameValue, nameRange } = getPluginDefinition(pluginNode);
const plugin = resolvePluginInfo(project.root, nameValue);
const plugin = resolvePluginInfo(project.root.fsPath, nameValue);

if (plugin) {
const link = new vscode.DocumentLink(
Expand All @@ -65,7 +65,7 @@ export class ManifestLinksProvider extends ExpoLinkProvider {
const range = getDocumentRange(document, reference.fileRange);

if (!pluginsRange?.contains(range)) {
const file = path.resolve(project.root, reference.filePath);
const file = path.resolve(project.root.fsPath, reference.filePath);
const link = new vscode.DocumentLink(range, vscode.Uri.file(file));

link.tooltip = 'Go to asset';
Expand Down
6 changes: 3 additions & 3 deletions src/manifestPluginCompletions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export class ManifestPluginCompletionsProvider extends ExpoCompletionsProvider {
) {
if (!this.isEnabled) return null;

const project = this.projects.fromManifest(document);
const project = await this.projects.fromManifest(document);
if (!project?.manifest) {
log('Could not resolve project from manifest "%s"', document.fileName);
return [];
Expand Down Expand Up @@ -76,7 +76,7 @@ export class ManifestPluginCompletionsProvider extends ExpoCompletionsProvider {
if (positionIsPath && !token.isCancellationRequested) {
const positionDir = getDirectoryPath(positionValue) ?? '';
const entities = await withCancelToken(token, () =>
vscode.workspace.fs.readDirectory(vscode.Uri.file(path.join(project.root, positionDir)))
vscode.workspace.fs.readDirectory(vscode.Uri.joinPath(project.root, positionDir))
);

return entities
Expand All @@ -91,7 +91,7 @@ export class ManifestPluginCompletionsProvider extends ExpoCompletionsProvider {

if (path.extname(entityName) === '.js') {
const pluginPath = './' + path.join(positionDir, entityName);
const plugin = resolvePluginInfo(project.root, pluginPath);
const plugin = resolvePluginInfo(project.root.fsPath, pluginPath);
if (plugin) {
return createPluginFile(plugin, entityName);
}
Expand Down
Loading

0 comments on commit 5a596d1

Please sign in to comment.