Path: blob/main/extensions/extension-editing/src/extensionLinter.ts
3291 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.");35const bumpEngineForImplicitActivationEvents = l10n.t("This activation event can be removed for extensions targeting engine version ^1.75 as VS Code will generate these automatically from your package.json contribution declarations.");36const 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?.minorBase >= 75;165// Redundant Implicit Activation166if (info.implicitActivationEvents?.has(activationEvent) && redundantImplicitActivationEventPrefixes.some((prefix) => activationEvent.startsWith(prefix))) {167const start = document.positionAt(activationEventNode.offset);168const end = document.positionAt(activationEventNode.offset + activationEventNode.length);169const message = isImplicitActivationSupported ? redundantImplicitActivationEvent : bumpEngineForImplicitActivationEvents;170diagnostics.push(new Diagnostic(new Range(start, end), message, isImplicitActivationSupported ? DiagnosticSeverity.Warning : DiagnosticSeverity.Information));171}172173// Reserved Implicit Activation174for (const implicitActivationEventPrefix of reservedImplicitActivationEventPrefixes) {175if (isImplicitActivationSupported && activationEvent.startsWith(implicitActivationEventPrefix)) {176const start = document.positionAt(activationEventNode.offset);177const end = document.positionAt(activationEventNode.offset + activationEventNode.length);178diagnostics.push(new Diagnostic(new Range(start, end), implicitActivationEvent, DiagnosticSeverity.Error));179}180}181182// Star activation183if (activationEvent === '*') {184const start = document.positionAt(activationEventNode.offset);185const end = document.positionAt(activationEventNode.offset + activationEventNode.length);186const diagnostic = new Diagnostic(new Range(start, end), starActivation, DiagnosticSeverity.Information);187diagnostic.code = {188value: 'star-activation',189target: Uri.parse('https://code.visualstudio.com/api/references/activation-events#Start-up'),190};191diagnostics.push(diagnostic);192}193}194}195196const whenClauseLinting = await this.lintWhenClauses(findNodeAtLocation(tree, ['contributes']), document);197diagnostics.push(...whenClauseLinting);198}199this.diagnosticsCollection.set(document.uri, diagnostics);200}201}202203/** lints `when` and `enablement` clauses */204private async lintWhenClauses(contributesNode: JsonNode | undefined, document: TextDocument): Promise<Diagnostic[]> {205if (!contributesNode) {206return [];207}208209const whenClauses: JsonNode[] = [];210211function findWhens(node: JsonNode | undefined, clauseName: string) {212if (node) {213switch (node.type) {214case 'property':215if (node.children && node.children.length === 2) {216const key = node.children[0];217const value = node.children[1];218switch (value.type) {219case 'string':220if (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 */) {221whenClauses.push(value);222}223case 'object':224case 'array':225findWhens(value, clauseName);226}227}228break;229case 'object':230case 'array':231if (node.children) {232node.children.forEach(n => findWhens(n, clauseName));233}234}235}236}237238[239findNodeAtLocation(contributesNode, ['menus']),240findNodeAtLocation(contributesNode, ['views']),241findNodeAtLocation(contributesNode, ['viewsWelcome']),242findNodeAtLocation(contributesNode, ['keybindings']),243].forEach(n => findWhens(n, 'when'));244245findWhens(findNodeAtLocation(contributesNode, ['commands']), 'enablement');246247const 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 */));248249const diagnostics: Diagnostic[] = [];250for (let i = 0; i < parseResults.length; ++i) {251const whenClauseJSONNode = whenClauses[i];252253const jsonStringScanner = new JsonStringScanner(document.getText(), whenClauseJSONNode.offset + 1);254255for (const error of parseResults[i]) {256const realOffset = jsonStringScanner.getOffsetInEncoded(error.offset);257const realOffsetEnd = jsonStringScanner.getOffsetInEncoded(error.offset + error.length);258const start = document.positionAt(realOffset /* +1 to account for the quote (I think) */);259const end = document.positionAt(realOffsetEnd);260const errMsg = `${parsingErrorHeader}\n\n${error.errorMessage}`;261const diagnostic = new Diagnostic(new Range(start, end), errMsg, DiagnosticSeverity.Error);262diagnostic.code = {263value: 'See docs',264target: Uri.parse('https://code.visualstudio.com/api/references/when-clause-contexts'),265};266diagnostics.push(diagnostic);267}268}269return diagnostics;270}271272private async lintReadme() {273for (const document of this.readmeQ) {274this.readmeQ.delete(document);275if (document.isClosed) {276continue;277}278279const folder = this.getUriFolder(document.uri);280let info = this.folderToPackageJsonInfo[folder.toString()];281if (!info) {282const tree = await this.loadPackageJson(folder);283info = this.readPackageJsonInfo(folder, tree);284}285if (!info.isExtension) {286this.diagnosticsCollection.set(document.uri, []);287return;288}289290const text = document.getText();291if (!this.markdownIt) {292this.markdownIt = new ((await import('markdown-it')).default);293}294const tokens = this.markdownIt.parse(text, {});295const tokensAndPositions: TokenAndPosition[] = (function toTokensAndPositions(this: ExtensionLinter, tokens: MarkdownItType.Token[], begin = 0, end = text.length): TokenAndPosition[] {296const tokensAndPositions = tokens.map<TokenAndPosition>(token => {297if (token.map) {298const tokenBegin = document.offsetAt(new Position(token.map[0], 0));299const tokenEnd = begin = document.offsetAt(new Position(token.map[1], 0));300return {301token,302begin: tokenBegin,303end: tokenEnd304};305}306const image = token.type === 'image' && this.locateToken(text, begin, end, token, token.attrGet('src'));307const other = image || this.locateToken(text, begin, end, token, token.content);308return other || {309token,310begin,311end: begin312};313});314return tokensAndPositions.concat(315...tokensAndPositions.filter(tnp => tnp.token.children && tnp.token.children.length)316.map(tnp => toTokensAndPositions.call(this, tnp.token.children, tnp.begin, tnp.end))317);318}).call(this, tokens);319320const diagnostics: Diagnostic[] = [];321322tokensAndPositions.filter(tnp => tnp.token.type === 'image' && tnp.token.attrGet('src'))323.map(inp => {324const src = inp.token.attrGet('src')!;325const begin = text.indexOf(src, inp.begin);326if (begin !== -1 && begin < inp.end) {327this.addDiagnostics(diagnostics, document, begin, begin + src.length, src, Context.MARKDOWN, info);328} else {329const content = inp.token.content;330const begin = text.indexOf(content, inp.begin);331if (begin !== -1 && begin < inp.end) {332this.addDiagnostics(diagnostics, document, begin, begin + content.length, src, Context.MARKDOWN, info);333}334}335});336337let svgStart: Diagnostic;338for (const tnp of tokensAndPositions) {339if (tnp.token.type === 'text' && tnp.token.content) {340if (!this.parse5) {341this.parse5 = await import('parse5');342}343const parser = new this.parse5.SAXParser({ locationInfo: true });344parser.on('startTag', (name, attrs, _selfClosing, location) => {345if (name === 'img') {346const src = attrs.find(a => a.name === 'src');347if (src && src.value && location) {348const begin = text.indexOf(src.value, tnp.begin + location.startOffset);349if (begin !== -1 && begin < tnp.end) {350this.addDiagnostics(diagnostics, document, begin, begin + src.value.length, src.value, Context.MARKDOWN, info);351}352}353} else if (name === 'svg' && location) {354const begin = tnp.begin + location.startOffset;355const end = tnp.begin + location.endOffset;356const range = new Range(document.positionAt(begin), document.positionAt(end));357svgStart = new Diagnostic(range, embeddedSvgsNotValid, DiagnosticSeverity.Warning);358diagnostics.push(svgStart);359}360});361parser.on('endTag', (name, location) => {362if (name === 'svg' && svgStart && location) {363const end = tnp.begin + location.endOffset;364svgStart.range = new Range(svgStart.range.start, document.positionAt(end));365}366});367parser.write(tnp.token.content);368parser.end();369}370}371372this.diagnosticsCollection.set(document.uri, diagnostics);373}374}375376private locateToken(text: string, begin: number, end: number, token: MarkdownItType.Token, content: string | null) {377if (content) {378const tokenBegin = text.indexOf(content, begin);379if (tokenBegin !== -1) {380const tokenEnd = tokenBegin + content.length;381if (tokenEnd <= end) {382begin = tokenEnd;383return {384token,385begin: tokenBegin,386end: tokenEnd387};388}389}390}391return undefined;392}393394private readPackageJsonInfo(folder: Uri, tree: JsonNode | undefined) {395const engine = tree && findNodeAtLocation(tree, ['engines', 'vscode']);396const parsedEngineVersion = engine?.type === 'string' ? normalizeVersion(parseVersion(engine.value)) : null;397const repo = tree && findNodeAtLocation(tree, ['repository', 'url']);398const uri = repo && parseUri(repo.value);399const activationEvents = tree && parseImplicitActivationEvents(tree);400401const info: PackageJsonInfo = {402isExtension: !!(engine && engine.type === 'string'),403hasHttpsRepository: !!(repo && repo.type === 'string' && repo.value && uri && uri.scheme.toLowerCase() === 'https'),404repository: uri!,405implicitActivationEvents: activationEvents,406engineVersion: parsedEngineVersion407};408const str = folder.toString();409const oldInfo = this.folderToPackageJsonInfo[str];410if (oldInfo && (oldInfo.isExtension !== info.isExtension || oldInfo.hasHttpsRepository !== info.hasHttpsRepository)) {411this.packageJsonChanged(folder); // clears this.folderToPackageJsonInfo[str]412}413this.folderToPackageJsonInfo[str] = info;414return info;415}416417private async loadPackageJson(folder: Uri) {418if (folder.scheme === 'git') { // #36236419return undefined;420}421const file = folder.with({ path: path.posix.join(folder.path, 'package.json') });422try {423const fileContents = await workspace.fs.readFile(file); // #174888424return parseTree(Buffer.from(fileContents).toString('utf-8'));425} catch (err) {426return undefined;427}428}429430private packageJsonChanged(folder: Uri) {431delete this.folderToPackageJsonInfo[folder.toString()];432const str = folder.toString().toLowerCase();433workspace.textDocuments.filter(document => this.getUriFolder(document.uri).toString().toLowerCase() === str)434.forEach(document => this.queueReadme(document));435}436437private getUriFolder(uri: Uri) {438return uri.with({ path: path.posix.dirname(uri.path) });439}440441private addDiagnostics(diagnostics: Diagnostic[], document: TextDocument, begin: number, end: number, src: string, context: Context, info: PackageJsonInfo) {442const hasScheme = /^\w[\w\d+.-]*:/.test(src);443const uri = parseUri(src, info.repository ? info.repository.toString() : document.uri.toString());444if (!uri) {445return;446}447const scheme = uri.scheme.toLowerCase();448449if (hasScheme && scheme !== 'https' && scheme !== 'data') {450const range = new Range(document.positionAt(begin), document.positionAt(end));451diagnostics.push(new Diagnostic(range, httpsRequired, DiagnosticSeverity.Warning));452}453454if (hasScheme && scheme === 'data') {455const range = new Range(document.positionAt(begin), document.positionAt(end));456diagnostics.push(new Diagnostic(range, dataUrlsNotValid, DiagnosticSeverity.Warning));457}458459if (!hasScheme && !info.hasHttpsRepository && context !== Context.ICON) {460const range = new Range(document.positionAt(begin), document.positionAt(end));461const message = (() => {462switch (context) {463case Context.BADGE: return relativeBadgeUrlRequiresHttpsRepository;464default: return relativeUrlRequiresHttpsRepository;465}466})();467diagnostics.push(new Diagnostic(range, message, DiagnosticSeverity.Warning));468}469470if (uri.path.toLowerCase().endsWith('.svg') && !isTrustedSVGSource(uri)) {471const range = new Range(document.positionAt(begin), document.positionAt(end));472diagnostics.push(new Diagnostic(range, svgsNotValid, DiagnosticSeverity.Warning));473}474}475476private clear(document: TextDocument) {477this.diagnosticsCollection.delete(document.uri);478this.packageJsonQ.delete(document);479}480481public dispose() {482this.disposables.forEach(d => d.dispose());483this.disposables = [];484}485}486487function parseUri(src: string, base?: string, retry: boolean = true): Uri | null {488try {489const url = new URL(src, base);490return Uri.parse(url.toString());491} catch (err) {492if (retry) {493return parseUri(encodeURI(src), base, false);494} else {495return null;496}497}498}499500function parseImplicitActivationEvents(tree: JsonNode): Set<string> {501const activationEvents = new Set<string>();502503// commands504const commands = findNodeAtLocation(tree, ['contributes', 'commands']);505commands?.children?.forEach(child => {506const command = findNodeAtLocation(child, ['command']);507if (command && command.type === 'string') {508activationEvents.add(`onCommand:${command.value}`);509}510});511512// authenticationProviders513const authenticationProviders = findNodeAtLocation(tree, ['contributes', 'authentication']);514authenticationProviders?.children?.forEach(child => {515const id = findNodeAtLocation(child, ['id']);516if (id && id.type === 'string') {517activationEvents.add(`onAuthenticationRequest:${id.value}`);518}519});520521// languages522const languageContributions = findNodeAtLocation(tree, ['contributes', 'languages']);523languageContributions?.children?.forEach(child => {524const id = findNodeAtLocation(child, ['id']);525const configuration = findNodeAtLocation(child, ['configuration']);526if (id && id.type === 'string' && configuration && configuration.type === 'string') {527activationEvents.add(`onLanguage:${id.value}`);528}529});530531// customEditors532const customEditors = findNodeAtLocation(tree, ['contributes', 'customEditors']);533customEditors?.children?.forEach(child => {534const viewType = findNodeAtLocation(child, ['viewType']);535if (viewType && viewType.type === 'string') {536activationEvents.add(`onCustomEditor:${viewType.value}`);537}538});539540// views541const viewContributions = findNodeAtLocation(tree, ['contributes', 'views']);542viewContributions?.children?.forEach(viewContribution => {543const views = viewContribution.children?.find((node) => node.type === 'array');544views?.children?.forEach(view => {545const id = findNodeAtLocation(view, ['id']);546if (id && id.type === 'string') {547activationEvents.add(`onView:${id.value}`);548}549});550});551552// walkthroughs553const walkthroughs = findNodeAtLocation(tree, ['contributes', 'walkthroughs']);554walkthroughs?.children?.forEach(child => {555const id = findNodeAtLocation(child, ['id']);556if (id && id.type === 'string') {557activationEvents.add(`onWalkthrough:${id.value}`);558}559});560561// notebookRenderers562const notebookRenderers = findNodeAtLocation(tree, ['contributes', 'notebookRenderer']);563notebookRenderers?.children?.forEach(child => {564const id = findNodeAtLocation(child, ['id']);565if (id && id.type === 'string') {566activationEvents.add(`onRenderer:${id.value}`);567}568});569570// terminalProfiles571const terminalProfiles = findNodeAtLocation(tree, ['contributes', 'terminal', 'profiles']);572terminalProfiles?.children?.forEach(child => {573const id = findNodeAtLocation(child, ['id']);574if (id && id.type === 'string') {575activationEvents.add(`onTerminalProfile:${id.value}`);576}577});578579// terminalQuickFixes580const terminalQuickFixes = findNodeAtLocation(tree, ['contributes', 'terminal', 'quickFixes']);581terminalQuickFixes?.children?.forEach(child => {582const id = findNodeAtLocation(child, ['id']);583if (id && id.type === 'string') {584activationEvents.add(`onTerminalQuickFixRequest:${id.value}`);585}586});587588// tasks589const tasks = findNodeAtLocation(tree, ['contributes', 'taskDefinitions']);590tasks?.children?.forEach(child => {591const id = findNodeAtLocation(child, ['type']);592if (id && id.type === 'string') {593activationEvents.add(`onTaskType:${id.value}`);594}595});596597return activationEvents;598}599600601