Skip to content

Commit

Permalink
feature: link to assets and file references (#39)
Browse files Browse the repository at this point in the history
  • Loading branch information
EvanBacon authored May 20, 2021
1 parent 3a51e18 commit 4696e70
Show file tree
Hide file tree
Showing 3 changed files with 130 additions and 33 deletions.
82 changes: 55 additions & 27 deletions src/manifest/configPlugins.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,30 @@
import {
resolveConfigPluginFunction,
resolvePluginForModule,
resolveConfigPluginFunctionWithInfo,
} from '@expo/config-plugins/build/utils/plugin-resolver';
import fs from 'fs';
import path from 'path';
import vscode, {
Diagnostic,
DiagnosticCollection,
DiagnosticSeverity,
DocumentLink,
languages,
Range,
TextDocument,
Uri,
window,
workspace,
} from 'vscode';

import { iterateFileReferences } from './fileReferences';
import { isConfigPluginValidationEnabled } from './settings';
import { ThrottledDelayer } from './utils/async';
import { getProjectRoot } from './utils/getProjectRoot';
import {
iteratePluginNames,
JsonRange,
parseSourceRanges,
PluginRange,
rangeForQuotedOffset,
} from './utils/iteratePlugins';
import { appJsonPattern, isAppJson, parseExpoJson } from './utils/parseExpoJson';

Expand All @@ -34,28 +35,44 @@ export function setupDefinition() {
// Enables jumping to source
vscode.languages.registerDocumentLinkProvider(appJsonPattern, {
provideDocumentLinks(document) {
const links: vscode.DocumentLink[] = [];

// Ensure we get the expo object if it exists.
const { node } = parseExpoJson(document.getText());
const links: vscode.DocumentLink[] = [];

if (!node) {
return links;
}

const projectRoot = getProjectRoot(document);

// Add links for plugin module resolvers in the plugins array.
iteratePluginNames(node, (resolver) => {
try {
const { filePath, isPluginFile } = resolvePluginForModule(
const { pluginFile } = resolveConfigPluginFunctionWithInfo(
projectRoot,
resolver.nameValue
);
const linkUri = Uri.parse(filePath);
const range = rangeForOffset(document, resolver.name);
const linkUri = Uri.parse(pluginFile);
const range = rangeForQuotedOffset(document, resolver.name);
const link = new DocumentLink(range, linkUri);
link.tooltip = isPluginFile
? `Go to ${resolver.nameValue}/app.plugin.js`
: 'Go to plugin';
link.tooltip = 'Go to config plugin';
links.push(link);
} catch {
// Invalid plugin.
// This should be formatted by validation
}
});

// Add links for any random file references starting with `"./` that aren't inside of the `plugins` array.
iterateFileReferences(document, node, ({ range, fileReference }) => {
const filePath = path.join(projectRoot, fileReference);
const linkUri = Uri.parse(filePath);
const link = new DocumentLink(range, linkUri);
link.tooltip = 'Go to asset';
links.push(link);
});

return links;
},
});
Expand All @@ -82,6 +99,8 @@ export function setupPluginsValidation(context: vscode.ExtensionContext) {
context.subscriptions
);

// TODO: Update on text change

validateAllDocuments();
}

Expand Down Expand Up @@ -130,32 +149,41 @@ function getPluginRanges(document: TextDocument) {
}

async function doValidate(document: TextDocument) {
const sourceRanges = getPluginRanges(document);
if (!sourceRanges?.length) {
const info = getPluginRanges(document);

if (!info?.appJson) {
return;
}

const projectRoot = getProjectRoot(document);

clearDiagnosticCollection();

const diagnostics: Diagnostic[] = [];
for (const plugin of sourceRanges) {
const diagnostic = getDiagnostic(projectRoot, document, plugin);
if (diagnostic) {
diagnostic.source = 'expo-config';
diagnostics.push(diagnostic);

if (info.plugins?.length) {
for (const plugin of info.plugins) {
const diagnostic = getDiagnostic(projectRoot, document, plugin);
if (diagnostic) {
diagnostic.source = 'expo-config';
diagnostics.push(diagnostic);
}
}
}

diagnosticCollection!.set(document.uri, diagnostics);
}
// Add errors for missing file references starting with `"./` that are not inside in the plugins array.
iterateFileReferences(document, info.appJson, ({ range, fileReference }) => {
const filePath = path.join(projectRoot, fileReference);

function rangeForOffset(document: TextDocument, source: JsonRange) {
return new Range(
document.positionAt(source.offset),
document.positionAt(source.offset + source.length)
);
try {
fs.statSync(filePath);
} catch (error) {
const diagnostic = new Diagnostic(range, error.message, DiagnosticSeverity.Error);
diagnostic.code = error.code;
diagnostics.push(diagnostic);
}
});

diagnosticCollection!.set(document.uri, diagnostics);
}

function getDiagnostic(
Expand All @@ -168,7 +196,7 @@ function getDiagnostic(
} catch (error) {
// If the plugin failed to load, surface the error info.
const source = plugin.name;
const range = rangeForOffset(document, source);
const range = rangeForQuotedOffset(document, source);
const diagnostic = new Diagnostic(range, error.message, DiagnosticSeverity.Error);
diagnostic.code = error.code;
return diagnostic;
Expand All @@ -178,7 +206,7 @@ function getDiagnostic(
// NOTE(EvanBacon): The JSON schema validates 3 or more items.
if (plugin.full && plugin.arrayLength != null && plugin.arrayLength < 2) {
// A plugin array should only be used to add props (i.e. two items).
const range = rangeForOffset(document, plugin.full);
const range = rangeForQuotedOffset(document, plugin.full);
// TODO: Link to a doc or FYI
const diagnostic = new Diagnostic(
range,
Expand Down
39 changes: 39 additions & 0 deletions src/manifest/fileReferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Node } from 'jsonc-parser';
import { Range, TextDocument } from 'vscode';

import { getPluginsArrayNode, rangeForOffset } from './utils/iteratePlugins';

function rangeForMatch(document: TextDocument, match: { index: number; length: number }) {
return new Range(
document.positionAt(match.index),
document.positionAt(match.index + match.length)
);
}

export function iterateFileReferences(
document: TextDocument,
node: Node | undefined,
callback: (props: { fileReference: string; range: Range; match: RegExpMatchArray }) => void
) {
const pluginsNode = getPluginsArrayNode(node);
const pluginsRange = pluginsNode ? rangeForOffset(document, pluginsNode) : null;

const matches = document.getText().matchAll(/"(\.\/.*)"/g);
for (const match of matches) {
if (match.index == null) {
continue;
}
const [, fileReference] = match;
const range = rangeForMatch(document, {
// index includes the quotes, so move it by 1 to exclude the first quote
index: match.index + 1,
length: fileReference.length,
});
// Avoid matching against file imports that are inside of the plugins object.
// Otherwise a plugin like `./my-plugin` would overwrite the better link we already created.
if (pluginsRange && pluginsRange.contains(range)) {
continue;
}
callback({ fileReference, range, match });
}
}
42 changes: 36 additions & 6 deletions src/manifest/utils/iteratePlugins.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Node } from 'jsonc-parser';
import { Position, Range, TextDocument } from 'vscode';

import { parseExpoJson } from './parseExpoJson';

Expand All @@ -19,20 +20,24 @@ export interface PluginRange {
props?: JsonRange;
}

function iteratePlugins(appJson: Node | undefined, iterator: (node: Node) => void) {
let pluginsNode: Node | undefined;
export function getPluginsArrayNode(appJson: Node | undefined) {
if (appJson?.children) {
for (const child of appJson.children) {
const children = child.children;
if (children) {
if (children && children.length === 2 && isPlugins(children[0].value)) {
pluginsNode = children[1];
break;
return children[1];
}
}
}
}

return null;
}

function iteratePlugins(appJson: Node | undefined, iterator: (node: Node) => void) {
const pluginsNode = getPluginsArrayNode(appJson);

if (pluginsNode?.children) {
pluginsNode.children.forEach(iterator);
}
Expand Down Expand Up @@ -80,7 +85,21 @@ function getPluginResolver(child?: Node): PluginRange | null {
return null;
}

export function parseSourceRanges(text: string): PluginRange[] {
export function rangeForOffset(document: TextDocument, source: JsonRange) {
return new Range(
document.positionAt(source.offset),
document.positionAt(source.offset + source.length)
);
}
export function rangeForQuotedOffset(document: TextDocument, source: JsonRange) {
// For nodes that have quotes
return new Range(
document.positionAt(source.offset + 1),
document.positionAt(source.offset + (source.length - 1))
);
}

export function parseSourceRanges(text: string): { appJson?: Node; plugins: PluginRange[] } {
const definedPlugins: PluginRange[] = [];
// Ensure we get the expo object if it exists.
const { node } = parseExpoJson(text);
Expand All @@ -89,7 +108,18 @@ export function parseSourceRanges(text: string): PluginRange[] {
definedPlugins.push(resolver);
});

return definedPlugins;
return { appJson: node, plugins: definedPlugins };
}

export function positionIsInPlugins(document: TextDocument, position: Position) {
const { node } = parseExpoJson(document.getText());
const pluginsNode = getPluginsArrayNode(node);
if (pluginsNode) {
const range = rangeForOffset(document, pluginsNode);
return range.contains(position);
}

return false;
}

function isPlugins(value: string) {
Expand Down

0 comments on commit 4696e70

Please sign in to comment.