From 4696e7089d709435b493512f193f8fde359bec7e Mon Sep 17 00:00:00 2001 From: Evan Bacon Date: Thu, 20 May 2021 11:19:22 -0700 Subject: [PATCH] feature: link to assets and file references (#39) --- src/manifest/configPlugins.ts | 82 +++++++++++++++++++--------- src/manifest/fileReferences.ts | 39 +++++++++++++ src/manifest/utils/iteratePlugins.ts | 42 ++++++++++++-- 3 files changed, 130 insertions(+), 33 deletions(-) create mode 100644 src/manifest/fileReferences.ts diff --git a/src/manifest/configPlugins.ts b/src/manifest/configPlugins.ts index 87485909..6d18ade7 100644 --- a/src/manifest/configPlugins.ts +++ b/src/manifest/configPlugins.ts @@ -1,7 +1,8 @@ import { resolveConfigPluginFunction, - resolvePluginForModule, + resolveConfigPluginFunctionWithInfo, } from '@expo/config-plugins/build/utils/plugin-resolver'; +import fs from 'fs'; import path from 'path'; import vscode, { Diagnostic, @@ -9,21 +10,21 @@ import vscode, { 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'; @@ -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; }, }); @@ -82,6 +99,8 @@ export function setupPluginsValidation(context: vscode.ExtensionContext) { context.subscriptions ); + // TODO: Update on text change + validateAllDocuments(); } @@ -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( @@ -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; @@ -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, diff --git a/src/manifest/fileReferences.ts b/src/manifest/fileReferences.ts new file mode 100644 index 00000000..4b25f2e3 --- /dev/null +++ b/src/manifest/fileReferences.ts @@ -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 }); + } +} diff --git a/src/manifest/utils/iteratePlugins.ts b/src/manifest/utils/iteratePlugins.ts index c41d5b2c..66801ad2 100644 --- a/src/manifest/utils/iteratePlugins.ts +++ b/src/manifest/utils/iteratePlugins.ts @@ -1,4 +1,5 @@ import { Node } from 'jsonc-parser'; +import { Position, Range, TextDocument } from 'vscode'; import { parseExpoJson } from './parseExpoJson'; @@ -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); } @@ -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); @@ -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) {