Path: blob/main/extensions/extension-editing/src/extensionLinter.ts
5256 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as path from 'path';6import * as fs from 'fs';7import { URL } from 'url';89import { parseTree, findNodeAtLocation, Node as JsonNode, getNodeValue } from 'jsonc-parser';10import * as MarkdownItType from 'markdown-it';1112import { commands, languages, workspace, Disposable, TextDocument, Uri, Diagnostic, Range, DiagnosticSeverity, Position, env, l10n } from 'vscode';13import { INormalizedVersion, normalizeVersion, parseVersion } from './extensionEngineValidation';14import { JsonStringScanner } from './jsonReconstruct';15import { implicitActivationEvent, redundantImplicitActivationEvent } from './constants';1617const product = JSON.parse(fs.readFileSync(path.join(env.appRoot, 'product.json'), { encoding: 'utf-8' }));18const allowedBadgeProviders: string[] = (product.extensionAllowedBadgeProviders || []).map((s: string) => s.toLowerCase());19const allowedBadgeProvidersRegex: RegExp[] = (product.extensionAllowedBadgeProvidersRegex || []).map((r: string) => new RegExp(r));20const extensionEnabledApiProposals: Record<string, string[]> = product.extensionEnabledApiProposals ?? {};21const reservedImplicitActivationEventPrefixes = ['onNotebookSerializer:'];22const redundantImplicitActivationEventPrefixes = ['onLanguage:', 'onView:', 'onAuthenticationRequest:', 'onCommand:', 'onCustomEditor:', 'onTerminalProfile:', 'onRenderer:', 'onTerminalQuickFixRequest:', 'onWalkthrough:'];2324function isTrustedSVGSource(uri: Uri): boolean {25return allowedBadgeProviders.includes(uri.authority.toLowerCase()) || allowedBadgeProvidersRegex.some(r => r.test(uri.toString()));26}2728const httpsRequired = l10n.t("Images must use the HTTPS protocol.");29const svgsNotValid = l10n.t("SVGs are not a valid image source.");30const embeddedSvgsNotValid = l10n.t("Embedded SVGs are not a valid image source.");31const dataUrlsNotValid = l10n.t("Data URLs are not a valid image source.");32const relativeUrlRequiresHttpsRepository = l10n.t("Relative image URLs require a repository with HTTPS protocol to be specified in the package.json.");33const relativeBadgeUrlRequiresHttpsRepository = l10n.t("Relative badge URLs require a repository with HTTPS protocol to be specified in this package.json.");34const apiProposalNotListed = l10n.t("This proposal cannot be used because for this extension the product defines a fixed set of API proposals. You can test your extension but before publishing you MUST reach out to the VS Code team.");3536const starActivation = l10n.t("Using '*' activation is usually a bad idea as it impacts performance.");37const parsingErrorHeader = l10n.t("Error parsing the when-clause:");3839enum Context {40ICON,41BADGE,42MARKDOWN43}4445interface TokenAndPosition {46token: MarkdownItType.Token;47begin: number;48end: number;49}5051interface PackageJsonInfo {52isExtension: boolean;53hasHttpsRepository: boolean;54repository: Uri;55implicitActivationEvents: Set<string> | undefined;56engineVersion: INormalizedVersion | null;57}5859export class ExtensionLinter {6061private diagnosticsCollection = languages.createDiagnosticCollection('extension-editing');62private fileWatcher = workspace.createFileSystemWatcher('**/package.json');63private disposables: Disposable[] = [this.diagnosticsCollection, this.fileWatcher];6465private folderToPackageJsonInfo: Record<string, PackageJsonInfo> = {};66private packageJsonQ = new Set<TextDocument>();67private readmeQ = new Set<TextDocument>();68private timer: NodeJS.Timeout | undefined;69private markdownIt: MarkdownItType.MarkdownIt | undefined;70private parse5: typeof import('parse5') | undefined;7172constructor() {73this.disposables.push(74workspace.onDidOpenTextDocument(document => this.queue(document)),75workspace.onDidChangeTextDocument(event => this.queue(event.document)),76workspace.onDidCloseTextDocument(document => this.clear(document)),77this.fileWatcher.onDidChange(uri => this.packageJsonChanged(this.getUriFolder(uri))),78this.fileWatcher.onDidCreate(uri => this.packageJsonChanged(this.getUriFolder(uri))),79this.fileWatcher.onDidDelete(uri => this.packageJsonChanged(this.getUriFolder(uri))),80);81workspace.textDocuments.forEach(document => this.queue(document));82}8384private queue(document: TextDocument) {85const p = document.uri.path;86if (document.languageId === 'json' && p.endsWith('/package.json')) {87this.packageJsonQ.add(document);88this.startTimer();89}90this.queueReadme(document);91}9293private queueReadme(document: TextDocument) {94const p = document.uri.path;95if (document.languageId === 'markdown' && (p.toLowerCase().endsWith('/readme.md') || p.toLowerCase().endsWith('/changelog.md'))) {96this.readmeQ.add(document);97this.startTimer();98}99}100101private startTimer() {102if (this.timer) {103clearTimeout(this.timer);104}105this.timer = setTimeout(() => {106this.lint()107.catch(console.error);108}, 300);109}110111private async lint() {112await Promise.all([113this.lintPackageJson(),114this.lintReadme()115]);116}117118private async lintPackageJson() {119for (const document of Array.from(this.packageJsonQ)) {120this.packageJsonQ.delete(document);121if (document.isClosed) {122continue;123}124125const diagnostics: Diagnostic[] = [];126127const tree = parseTree(document.getText());128const info = this.readPackageJsonInfo(this.getUriFolder(document.uri), tree);129if (tree && info.isExtension) {130131const icon = findNodeAtLocation(tree, ['icon']);132if (icon && icon.type === 'string') {133this.addDiagnostics(diagnostics, document, icon.offset + 1, icon.offset + icon.length - 1, icon.value, Context.ICON, info);134}135136const badges = findNodeAtLocation(tree, ['badges']);137if (badges && badges.type === 'array' && badges.children) {138badges.children.map(child => findNodeAtLocation(child, ['url']))139.filter(url => url && url.type === 'string')140.map(url => this.addDiagnostics(diagnostics, document, url!.offset + 1, url!.offset + url!.length - 1, url!.value, Context.BADGE, info));141}142143const publisher = findNodeAtLocation(tree, ['publisher']);144const name = findNodeAtLocation(tree, ['name']);145const enabledApiProposals = findNodeAtLocation(tree, ['enabledApiProposals']);146if (publisher?.type === 'string' && name?.type === 'string' && enabledApiProposals?.type === 'array') {147const extensionId = `${getNodeValue(publisher)}.${getNodeValue(name)}`;148const effectiveProposalNames = extensionEnabledApiProposals[extensionId];149if (Array.isArray(effectiveProposalNames) && enabledApiProposals.children) {150for (const child of enabledApiProposals.children) {151const proposalName = child.type === 'string' ? getNodeValue(child) : undefined;152if (typeof proposalName === 'string' && !effectiveProposalNames.includes(proposalName.split('@')[0])) {153const start = document.positionAt(child.offset);154const end = document.positionAt(child.offset + child.length);155diagnostics.push(new Diagnostic(new Range(start, end), apiProposalNotListed, DiagnosticSeverity.Error));156}157}158}159}160const activationEventsNode = findNodeAtLocation(tree, ['activationEvents']);161if (activationEventsNode?.type === 'array' && activationEventsNode.children) {162for (const activationEventNode of activationEventsNode.children) {163const activationEvent = getNodeValue(activationEventNode);164const isImplicitActivationSupported = info.engineVersion && (info.engineVersion.majorBase > 1 || (info.engineVersion.majorBase === 1 && info.engineVersion.minorBase >= 75));165// Redundant Implicit Activation166if (isImplicitActivationSupported && info.implicitActivationEvents?.has(activationEvent) && redundantImplicitActivationEventPrefixes.some((prefix) => activationEvent.startsWith(prefix))) {167const start = document.positionAt(activationEventNode.offset);168const end = document.positionAt(activationEventNode.offset + activationEventNode.length);169diagnostics.push(new Diagnostic(new Range(start, end), redundantImplicitActivationEvent, DiagnosticSeverity.Warning));170}171172// Reserved Implicit Activation173for (const implicitActivationEventPrefix of reservedImplicitActivationEventPrefixes) {174if (isImplicitActivationSupported && activationEvent.startsWith(implicitActivationEventPrefix)) {175const start = document.positionAt(activationEventNode.offset);176const end = document.positionAt(activationEventNode.offset + activationEventNode.length);177diagnostics.push(new Diagnostic(new Range(start, end), implicitActivationEvent, DiagnosticSeverity.Error));178}179}180181// Star activation182if (activationEvent === '*') {183const start = document.positionAt(activationEventNode.offset);184const end = document.positionAt(activationEventNode.offset + activationEventNode.length);185const diagnostic = new Diagnostic(new Range(start, end), starActivation, DiagnosticSeverity.Information);186diagnostic.code = {187value: 'star-activation',188target: Uri.parse('https://code.visualstudio.com/api/references/activation-events#Start-up'),189};190diagnostics.push(diagnostic);191}192}193}194195const whenClauseLinting = await this.lintWhenClauses(findNodeAtLocation(tree, ['contributes']), document);196diagnostics.push(...whenClauseLinting);197}198this.diagnosticsCollection.set(document.uri, diagnostics);199}200}201202/** lints `when` and `enablement` clauses */203private async lintWhenClauses(contributesNode: JsonNode | undefined, document: TextDocument): Promise<Diagnostic[]> {204if (!contributesNode) {205return [];206}207208const whenClauses: JsonNode[] = [];209210function findWhens(node: JsonNode | undefined, clauseName: string) {211if (node) {212switch (node.type) {213case 'property':214if (node.children && node.children.length === 2) {215const key = node.children[0];216const value = node.children[1];217switch (value.type) {218case 'string':219if (key.value === clauseName && typeof value.value === 'string' /* careful: `.value` MUST be a string 1) because a when/enablement clause is string; so also, type cast to string below is safe */) {220whenClauses.push(value);221}222case 'object':223case 'array':224findWhens(value, clauseName);225}226}227break;228case 'object':229case 'array':230if (node.children) {231node.children.forEach(n => findWhens(n, clauseName));232}233}234}235}236237[238findNodeAtLocation(contributesNode, ['menus']),239findNodeAtLocation(contributesNode, ['views']),240findNodeAtLocation(contributesNode, ['viewsWelcome']),241findNodeAtLocation(contributesNode, ['keybindings']),242].forEach(n => findWhens(n, 'when'));243244findWhens(findNodeAtLocation(contributesNode, ['commands']), 'enablement');245246const parseResults = await commands.executeCommand<{ errorMessage: string; offset: number; length: number }[][]>('_validateWhenClauses', whenClauses.map(w => w.value as string /* we make sure to capture only if `w.value` is string above */));247248const diagnostics: Diagnostic[] = [];249for (let i = 0; i < parseResults.length; ++i) {250const whenClauseJSONNode = whenClauses[i];251252const jsonStringScanner = new JsonStringScanner(document.getText(), whenClauseJSONNode.offset + 1);253254for (const error of parseResults[i]) {255const realOffset = jsonStringScanner.getOffsetInEncoded(error.offset);256const realOffsetEnd = jsonStringScanner.getOffsetInEncoded(error.offset + error.length);257const start = document.positionAt(realOffset /* +1 to account for the quote (I think) */);258const end = document.positionAt(realOffsetEnd);259const errMsg = `${parsingErrorHeader}\n\n${error.errorMessage}`;260const diagnostic = new Diagnostic(new Range(start, end), errMsg, DiagnosticSeverity.Error);261diagnostic.code = {262value: 'See docs',263target: Uri.parse('https://code.visualstudio.com/api/references/when-clause-contexts'),264};265diagnostics.push(diagnostic);266}267}268return diagnostics;269}270271private async lintReadme() {272for (const document of this.readmeQ) {273this.readmeQ.delete(document);274if (document.isClosed) {275continue;276}277278const folder = this.getUriFolder(document.uri);279let info = this.folderToPackageJsonInfo[folder.toString()];280if (!info) {281const tree = await this.loadPackageJson(folder);282info = this.readPackageJsonInfo(folder, tree);283}284if (!info.isExtension) {285this.diagnosticsCollection.set(document.uri, []);286return;287}288289const text = document.getText();290if (!this.markdownIt) {291this.markdownIt = new ((await import('markdown-it')).default);292}293const tokens = this.markdownIt.parse(text, {});294const tokensAndPositions: TokenAndPosition[] = (function toTokensAndPositions(this: ExtensionLinter, tokens: MarkdownItType.Token[], begin = 0, end = text.length): TokenAndPosition[] {295const tokensAndPositions = tokens.map<TokenAndPosition>(token => {296if (token.map) {297const tokenBegin = document.offsetAt(new Position(token.map[0], 0));298const tokenEnd = begin = document.offsetAt(new Position(token.map[1], 0));299return {300token,301begin: tokenBegin,302end: tokenEnd303};304}305const image = token.type === 'image' && this.locateToken(text, begin, end, token, token.attrGet('src'));306const other = image || this.locateToken(text, begin, end, token, token.content);307return other || {308token,309begin,310end: begin311};312});313return tokensAndPositions.concat(314...tokensAndPositions.filter(tnp => tnp.token.children && tnp.token.children.length)315.map(tnp => toTokensAndPositions.call(this, tnp.token.children, tnp.begin, tnp.end))316);317}).call(this, tokens);318319const diagnostics: Diagnostic[] = [];320321tokensAndPositions.filter(tnp => tnp.token.type === 'image' && tnp.token.attrGet('src'))322.map(inp => {323const src = inp.token.attrGet('src')!;324const begin = text.indexOf(src, inp.begin);325if (begin !== -1 && begin < inp.end) {326this.addDiagnostics(diagnostics, document, begin, begin + src.length, src, Context.MARKDOWN, info);327} else {328const content = inp.token.content;329const begin = text.indexOf(content, inp.begin);330if (begin !== -1 && begin < inp.end) {331this.addDiagnostics(diagnostics, document, begin, begin + content.length, src, Context.MARKDOWN, info);332}333}334});335336let svgStart: Diagnostic;337for (const tnp of tokensAndPositions) {338if (tnp.token.type === 'text' && tnp.token.content) {339if (!this.parse5) {340this.parse5 = await import('parse5');341}342const parser = new this.parse5.SAXParser({ locationInfo: true });343parser.on('startTag', (name, attrs, _selfClosing, location) => {344if (name === 'img') {345const src = attrs.find(a => a.name === 'src');346if (src && src.value && location) {347const begin = text.indexOf(src.value, tnp.begin + location.startOffset);348if (begin !== -1 && begin < tnp.end) {349this.addDiagnostics(diagnostics, document, begin, begin + src.value.length, src.value, Context.MARKDOWN, info);350}351}352} else if (name === 'svg' && location) {353const begin = tnp.begin + location.startOffset;354const end = tnp.begin + location.endOffset;355const range = new Range(document.positionAt(begin), document.positionAt(end));356svgStart = new Diagnostic(range, embeddedSvgsNotValid, DiagnosticSeverity.Warning);357diagnostics.push(svgStart);358}359});360parser.on('endTag', (name, location) => {361if (name === 'svg' && svgStart && location) {362const end = tnp.begin + location.endOffset;363svgStart.range = new Range(svgStart.range.start, document.positionAt(end));364}365});366parser.write(tnp.token.content);367parser.end();368}369}370371this.diagnosticsCollection.set(document.uri, diagnostics);372}373}374375private locateToken(text: string, begin: number, end: number, token: MarkdownItType.Token, content: string | null) {376if (content) {377const tokenBegin = text.indexOf(content, begin);378if (tokenBegin !== -1) {379const tokenEnd = tokenBegin + content.length;380if (tokenEnd <= end) {381begin = tokenEnd;382return {383token,384begin: tokenBegin,385end: tokenEnd386};387}388}389}390return undefined;391}392393private readPackageJsonInfo(folder: Uri, tree: JsonNode | undefined) {394const engine = tree && findNodeAtLocation(tree, ['engines', 'vscode']);395const parsedEngineVersion = engine?.type === 'string' ? normalizeVersion(parseVersion(engine.value)) : null;396const repo = tree && findNodeAtLocation(tree, ['repository', 'url']);397const uri = repo && parseUri(repo.value);398const activationEvents = tree && parseImplicitActivationEvents(tree);399400const info: PackageJsonInfo = {401isExtension: !!(engine && engine.type === 'string'),402hasHttpsRepository: !!(repo && repo.type === 'string' && repo.value && uri && uri.scheme.toLowerCase() === 'https'),403repository: uri!,404implicitActivationEvents: activationEvents,405engineVersion: parsedEngineVersion406};407const str = folder.toString();408const oldInfo = this.folderToPackageJsonInfo[str];409if (oldInfo && (oldInfo.isExtension !== info.isExtension || oldInfo.hasHttpsRepository !== info.hasHttpsRepository)) {410this.packageJsonChanged(folder); // clears this.folderToPackageJsonInfo[str]411}412this.folderToPackageJsonInfo[str] = info;413return info;414}415416private async loadPackageJson(folder: Uri) {417if (folder.scheme === 'git') { // #36236418return undefined;419}420const file = folder.with({ path: path.posix.join(folder.path, 'package.json') });421try {422const fileContents = await workspace.fs.readFile(file); // #174888423return parseTree(Buffer.from(fileContents).toString('utf-8'));424} catch (err) {425return undefined;426}427}428429private packageJsonChanged(folder: Uri) {430delete this.folderToPackageJsonInfo[folder.toString()];431const str = folder.toString().toLowerCase();432workspace.textDocuments.filter(document => this.getUriFolder(document.uri).toString().toLowerCase() === str)433.forEach(document => this.queueReadme(document));434}435436private getUriFolder(uri: Uri) {437return uri.with({ path: path.posix.dirname(uri.path) });438}439440private addDiagnostics(diagnostics: Diagnostic[], document: TextDocument, begin: number, end: number, src: string, context: Context, info: PackageJsonInfo) {441const hasScheme = /^\w[\w\d+.-]*:/.test(src);442const uri = parseUri(src, info.repository ? info.repository.toString() : document.uri.toString());443if (!uri) {444return;445}446const scheme = uri.scheme.toLowerCase();447448if (hasScheme && scheme !== 'https' && scheme !== 'data') {449const range = new Range(document.positionAt(begin), document.positionAt(end));450diagnostics.push(new Diagnostic(range, httpsRequired, DiagnosticSeverity.Warning));451}452453if (hasScheme && scheme === 'data') {454const range = new Range(document.positionAt(begin), document.positionAt(end));455diagnostics.push(new Diagnostic(range, dataUrlsNotValid, DiagnosticSeverity.Warning));456}457458if (!hasScheme && !info.hasHttpsRepository && context !== Context.ICON) {459const range = new Range(document.positionAt(begin), document.positionAt(end));460const message = (() => {461switch (context) {462case Context.BADGE: return relativeBadgeUrlRequiresHttpsRepository;463default: return relativeUrlRequiresHttpsRepository;464}465})();466diagnostics.push(new Diagnostic(range, message, DiagnosticSeverity.Warning));467}468469if (uri.path.toLowerCase().endsWith('.svg') && !isTrustedSVGSource(uri)) {470const range = new Range(document.positionAt(begin), document.positionAt(end));471diagnostics.push(new Diagnostic(range, svgsNotValid, DiagnosticSeverity.Warning));472}473}474475private clear(document: TextDocument) {476this.diagnosticsCollection.delete(document.uri);477this.packageJsonQ.delete(document);478}479480public dispose() {481this.disposables.forEach(d => d.dispose());482this.disposables = [];483}484}485486function parseUri(src: string, base?: string, retry: boolean = true): Uri | null {487try {488const url = new URL(src, base);489return Uri.parse(url.toString());490} catch (err) {491if (retry) {492return parseUri(encodeURI(src), base, false);493} else {494return null;495}496}497}498499function parseImplicitActivationEvents(tree: JsonNode): Set<string> {500const activationEvents = new Set<string>();501502// commands503const commands = findNodeAtLocation(tree, ['contributes', 'commands']);504commands?.children?.forEach(child => {505const command = findNodeAtLocation(child, ['command']);506if (command && command.type === 'string') {507activationEvents.add(`onCommand:${command.value}`);508}509});510511// authenticationProviders512const authenticationProviders = findNodeAtLocation(tree, ['contributes', 'authentication']);513authenticationProviders?.children?.forEach(child => {514const id = findNodeAtLocation(child, ['id']);515if (id && id.type === 'string') {516activationEvents.add(`onAuthenticationRequest:${id.value}`);517}518});519520// languages521const languageContributions = findNodeAtLocation(tree, ['contributes', 'languages']);522languageContributions?.children?.forEach(child => {523const id = findNodeAtLocation(child, ['id']);524const configuration = findNodeAtLocation(child, ['configuration']);525if (id && id.type === 'string' && configuration && configuration.type === 'string') {526activationEvents.add(`onLanguage:${id.value}`);527}528});529530// customEditors531const customEditors = findNodeAtLocation(tree, ['contributes', 'customEditors']);532customEditors?.children?.forEach(child => {533const viewType = findNodeAtLocation(child, ['viewType']);534if (viewType && viewType.type === 'string') {535activationEvents.add(`onCustomEditor:${viewType.value}`);536}537});538539// views540const viewContributions = findNodeAtLocation(tree, ['contributes', 'views']);541viewContributions?.children?.forEach(viewContribution => {542const views = viewContribution.children?.find((node) => node.type === 'array');543views?.children?.forEach(view => {544const id = findNodeAtLocation(view, ['id']);545if (id && id.type === 'string') {546activationEvents.add(`onView:${id.value}`);547}548});549});550551// walkthroughs552const walkthroughs = findNodeAtLocation(tree, ['contributes', 'walkthroughs']);553walkthroughs?.children?.forEach(child => {554const id = findNodeAtLocation(child, ['id']);555if (id && id.type === 'string') {556activationEvents.add(`onWalkthrough:${id.value}`);557}558});559560// notebookRenderers561const notebookRenderers = findNodeAtLocation(tree, ['contributes', 'notebookRenderer']);562notebookRenderers?.children?.forEach(child => {563const id = findNodeAtLocation(child, ['id']);564if (id && id.type === 'string') {565activationEvents.add(`onRenderer:${id.value}`);566}567});568569// terminalProfiles570const terminalProfiles = findNodeAtLocation(tree, ['contributes', 'terminal', 'profiles']);571terminalProfiles?.children?.forEach(child => {572const id = findNodeAtLocation(child, ['id']);573if (id && id.type === 'string') {574activationEvents.add(`onTerminalProfile:${id.value}`);575}576});577578// terminalQuickFixes579const terminalQuickFixes = findNodeAtLocation(tree, ['contributes', 'terminal', 'quickFixes']);580terminalQuickFixes?.children?.forEach(child => {581const id = findNodeAtLocation(child, ['id']);582if (id && id.type === 'string') {583activationEvents.add(`onTerminalQuickFixRequest:${id.value}`);584}585});586587// tasks588const tasks = findNodeAtLocation(tree, ['contributes', 'taskDefinitions']);589tasks?.children?.forEach(child => {590const id = findNodeAtLocation(child, ['type']);591if (id && id.type === 'string') {592activationEvents.add(`onTaskType:${id.value}`);593}594});595596return activationEvents;597}598599600