From 997bcf9001a30d25876638df35bde934ac2ec0aa Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Fri, 23 Jun 2017 23:20:31 -0700 Subject: [PATCH] Add linting for extensions --- .../extension-editing/npm-shrinkwrap.json | 74 +++- extensions/extension-editing/package.json | 6 +- extensions/extension-editing/src/extension.ts | 3 + .../extension-editing/src/extensionLinter.ts | 316 ++++++++++++++++++ .../extension-editing/src/typings/ref.d.ts | 1 - 5 files changed, 384 insertions(+), 16 deletions(-) create mode 100644 extensions/extension-editing/src/extensionLinter.ts diff --git a/extensions/extension-editing/npm-shrinkwrap.json b/extensions/extension-editing/npm-shrinkwrap.json index 08f1d9351e7..c546fa26f89 100644 --- a/extensions/extension-editing/npm-shrinkwrap.json +++ b/extensions/extension-editing/npm-shrinkwrap.json @@ -1,15 +1,61 @@ { - "name": "extension-editing", - "version": "0.0.1", - "dependencies": { - "jsonc-parser": { - "version": "0.3.1", - "from": "jsonc-parser@0.3.1" - }, - "vscode-nls": { - "version": "2.0.2", - "from": "vscode-nls@>=2.0.2 <3.0.0", - "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-2.0.2.tgz" - } - } -} \ No newline at end of file + "name": "extension-editing", + "version": "0.0.1", + "dependencies": { + "@types/node": { + "version": "6.0.78", + "from": "@types/node@>=6.0.46 <7.0.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-6.0.78.tgz" + }, + "argparse": { + "version": "1.0.9", + "from": "argparse@>=1.0.7 <2.0.0", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.9.tgz" + }, + "entities": { + "version": "1.1.1", + "from": "entities@>=1.1.1 <1.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.1.tgz" + }, + "jsonc-parser": { + "version": "0.3.1", + "from": "jsonc-parser@>=0.3.1 <0.4.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-0.3.1.tgz" + }, + "linkify-it": { + "version": "2.0.3", + "from": "linkify-it@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.0.3.tgz" + }, + "markdown-it": { + "version": "8.3.1", + "from": "markdown-it@>=8.3.1 <9.0.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-8.3.1.tgz" + }, + "mdurl": { + "version": "1.0.1", + "from": "mdurl@>=1.0.1 <2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz" + }, + "parse5": { + "version": "3.0.2", + "from": "parse5@>=3.0.2 <4.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-3.0.2.tgz" + }, + "sprintf-js": { + "version": "1.0.3", + "from": "sprintf-js@>=1.0.2 <1.1.0", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz" + }, + "uc.micro": { + "version": "1.0.3", + "from": "uc.micro@>=1.0.3 <2.0.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.3.tgz" + }, + "vscode-nls": { + "version": "2.0.2", + "from": "vscode-nls@>=2.0.1 <3.0.0", + "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-2.0.2.tgz" + } + } +} diff --git a/extensions/extension-editing/package.json b/extensions/extension-editing/package.json index a5aa159da05..36f1ae658ab 100644 --- a/extensions/extension-editing/package.json +++ b/extensions/extension-editing/package.json @@ -11,6 +11,7 @@ ], "activationEvents": [ "onLanguage:json", + "onLanguage:markdown", "onLanguage:typescript" ], "main": "./out/extension", @@ -20,6 +21,8 @@ }, "dependencies": { "jsonc-parser": "^0.3.1", + "markdown-it": "^8.3.1", + "parse5": "^3.0.2", "vscode-nls": "^2.0.1" }, "contributes": { @@ -43,6 +46,7 @@ ] }, "devDependencies": { + "@types/markdown-it": "0.0.2", "@types/node": "^7.0.4" } -} \ No newline at end of file +} diff --git a/extensions/extension-editing/src/extension.ts b/extensions/extension-editing/src/extension.ts index 647385b059b..9e648bc9264 100644 --- a/extensions/extension-editing/src/extension.ts +++ b/extensions/extension-editing/src/extension.ts @@ -8,6 +8,7 @@ import * as vscode from 'vscode'; import * as ts from 'typescript'; import { PackageDocument } from './packageDocumentHelper'; +import { ExtensionLinter } from './extensionLinter'; export function activate(context: vscode.ExtensionContext) { const registration = vscode.languages.registerDocumentLinkProvider({ language: 'typescript', pattern: '**/vscode.d.ts' }, _linkProvider); @@ -15,6 +16,8 @@ export function activate(context: vscode.ExtensionContext) { //package.json suggestions context.subscriptions.push(registerPackageDocumentCompletions()); + + context.subscriptions.push(new ExtensionLinter(context)); } const _linkProvider = new class implements vscode.DocumentLinkProvider { diff --git a/extensions/extension-editing/src/extensionLinter.ts b/extensions/extension-editing/src/extensionLinter.ts new file mode 100644 index 00000000000..209c7ffee1f --- /dev/null +++ b/extensions/extension-editing/src/extensionLinter.ts @@ -0,0 +1,316 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'fs'; +import * as path from 'path'; + +import { parseTree, findNodeAtLocation, Node as JsonNode } from 'jsonc-parser'; +import * as nls from 'vscode-nls'; +import * as MarkdownIt from 'markdown-it'; +import * as parse5 from 'parse5'; + +import { languages, workspace, Disposable, ExtensionContext, TextDocument, Uri, Diagnostic, Range, DiagnosticSeverity, Position } from 'vscode'; + +const product = require('../../../product.json'); +const allowedBadgeProviders: string[] = (product.extensionAllowedBadgeProviders || []).map(s => s.toLowerCase()); + +const localize = nls.loadMessageBundle(); + +const httpsRequired = localize('httpsRequired', "Images must use the HTTPS protocol."); +const svgsNotValid = localize('svgsNotValid', "SVGs are not a valid image source."); +const dataUrlsNotValid = localize('dataUrlsNotValid', "Data URLs are not a valid image source."); +const relativeUrlRequiresHttpsRepository = localize('relativeUrlRequiresHttpsRepository', "Relative image URLs require a repository with HTTPS protocol in the package.json."); + +enum Context { + ICON, + BADGE, + MARKDOWN +} + +interface TokenAndPosition { + token: MarkdownIt.Token; + begin: number; + end: number; +} + +interface PackageJsonInfo { + isExtension: boolean; + hasHttpsRepository: boolean; +} + +export class ExtensionLinter { + + private diagnosticsCollection = languages.createDiagnosticCollection('extension-editing'); + private fileWatcher = workspace.createFileSystemWatcher('**/package.json'); + private disposables: Disposable[] = [this.diagnosticsCollection, this.fileWatcher]; + + private folderToPackageJsonInfo: Record = {}; + private packageJsonQ = new Set(); + private readmeQ = new Set(); + private timer: NodeJS.Timer; + private markdownIt = new MarkdownIt(); + + constructor(private context: ExtensionContext) { + this.disposables.push( + workspace.onDidOpenTextDocument(document => this.queue(document)), + workspace.onDidChangeTextDocument(event => this.queue(event.document)), + workspace.onDidCloseTextDocument(document => this.clear(document)), + this.fileWatcher.onDidChange(uri => this.packageJsonChanged(this.getUriFolder(uri))), + this.fileWatcher.onDidCreate(uri => this.packageJsonChanged(this.getUriFolder(uri))), + this.fileWatcher.onDidDelete(uri => this.packageJsonChanged(this.getUriFolder(uri))), + ); + workspace.textDocuments.forEach(document => this.queue(document)); + } + + private queue(document: TextDocument) { + const p = document.uri.path; + if (document.languageId === 'json' && endsWith(p, '/package.json')) { + this.packageJsonQ.add(document); + this.startTimer(); + } + this.queueReadme(document); + } + + private queueReadme(document: TextDocument) { + const p = document.uri.path; + if (document.languageId === 'markdown' && (endsWith(p.toLowerCase(), '/readme.md') || endsWith(p.toLowerCase(), '/changelog.md'))) { + this.readmeQ.add(document); + this.startTimer(); + } + } + + private startTimer() { + if (this.timer) { + clearTimeout(this.timer); + } + this.timer = setTimeout(() => { + this.lint() + .catch(console.error); + }, 300); + } + + private async lint() { + this.lintPackageJson(); + await this.lintReadme(); + } + + private lintPackageJson() { + this.packageJsonQ.forEach(document => { + this.packageJsonQ.delete(document); + if (document.isClosed) { + return; + } + + const diagnostics: Diagnostic[] = []; + + const tree = parseTree(document.getText()); + const info = this.readPackageJsonInfo(this.getUriFolder(document.uri), tree); + if (info.isExtension) { + + const icon = findNodeAtLocation(tree, ['icon']); + if (icon && icon.type === 'string') { + this.addDiagnostics(diagnostics, document, icon.offset + 1, icon.offset + icon.length - 1, icon.value, Context.ICON, info); + } + + const badges = findNodeAtLocation(tree, ['badges']); + if (badges && badges.type === 'array') { + badges.children.map(child => findNodeAtLocation(child, ['url'])) + .filter(url => url && url.type === 'string') + .map(url => this.addDiagnostics(diagnostics, document, url.offset + 1, url.offset + url.length - 1, url.value, Context.BADGE, info)); + } + + } + this.diagnosticsCollection.set(document.uri, diagnostics); + }); + } + + private async lintReadme() { + for (const document of Array.from(this.readmeQ)) { + this.readmeQ.delete(document); + if (document.isClosed) { + return; + } + + const folder = this.getUriFolder(document.uri); + let info = this.folderToPackageJsonInfo[folder.toString()]; + if (!info) { + const tree = await this.loadPackageJson(folder); + info = this.readPackageJsonInfo(folder, tree); + } + if (!info.isExtension) { + this.diagnosticsCollection.set(document.uri, []); + return; + } + + const text = document.getText(); + const tokens = this.markdownIt.parse(text, {}); + const tokensAndPositions = (function toTokensAndPositions(tokens: MarkdownIt.Token[], begin = 0, end = text.length): TokenAndPosition[] { + const tokensAndPositions = tokens.map(token => { + if (token.map) { + const tokenBegin = document.offsetAt(new Position(token.map[0], 0)); + const tokenEnd = begin = document.offsetAt(new Position(token.map[1], 0)); + return { + token, + begin: tokenBegin, + end: tokenEnd + }; + } + const content = token.type === 'image' && token.attrGet('src') || token.content; + if (content) { + const tokenBegin = text.indexOf(content, begin); + if (tokenBegin !== -1) { + const tokenEnd = tokenBegin + content.length; + if (tokenEnd <= end) { + begin = tokenEnd; + return { + token, + begin: tokenBegin, + end: tokenEnd + }; + } + } + } + return { + token, + begin, + end: begin + }; + }); + return tokensAndPositions.concat( + ...tokensAndPositions.filter(tnp => tnp.token.children && tnp.token.children.length) + .map(tnp => toTokensAndPositions(tnp.token.children, tnp.begin, tnp.end)) + ); + })(tokens); + + const diagnostics: Diagnostic[] = []; + + tokensAndPositions.filter(tnp => tnp.token.type === 'image' && tnp.token.attrGet('src')) + .map(inp => { + const src = inp.token.attrGet('src'); + const begin = text.indexOf(src, inp.begin); + if (begin !== -1 && begin < inp.end) { + this.addDiagnostics(diagnostics, document, begin, begin + src.length, src, Context.MARKDOWN, info); + } + }); + + tokensAndPositions.filter(tnp => tnp.token.type === 'text' && tnp.token.content) + .map(tnp => { + const parser = new parse5.SAXParser({ locationInfo: true }); + parser.on('startTag', (name, attrs, selfClosing, location) => { + if (name === 'img') { + const src = attrs.find(a => a.name === 'src'); + if (src && src.value) { + const begin = text.indexOf(src.value, tnp.begin + location.startOffset); + if (begin !== -1 && begin < tnp.end) { + this.addDiagnostics(diagnostics, document, begin, begin + src.value.length, src.value, Context.MARKDOWN, info); + } + } + } + }); + parser.write(tnp.token.content); + parser.end(); + }); + + this.diagnosticsCollection.set(document.uri, diagnostics); + }; + } + + private readPackageJsonInfo(folder: Uri, tree: JsonNode) { + const engine = tree && findNodeAtLocation(tree, ['engines', 'vscode']); + const repo = tree && findNodeAtLocation(tree, ['repository', 'url']); + const info: PackageJsonInfo = { + isExtension: !!(engine && engine.type === 'string'), + hasHttpsRepository: !!(repo && repo.type === 'string' && repo.value && Uri.parse(repo.value).scheme.toLowerCase() === 'https') + }; + const str = folder.toString(); + const oldInfo = this.folderToPackageJsonInfo[str]; + if (oldInfo && (oldInfo.isExtension !== info.isExtension || oldInfo.hasHttpsRepository !== info.hasHttpsRepository)) { + this.packageJsonChanged(folder); // clears this.folderToPackageJsonInfo[str] + } + this.folderToPackageJsonInfo[str] = info; + return info; + } + + private async loadPackageJson(folder: Uri) { + const file = folder.with({ path: path.posix.join(folder.path, 'package.json') }); + const exists = await fileExists(file.fsPath); + if (!exists) { + return undefined; + } + const document = await workspace.openTextDocument(file); + return parseTree(document.getText()); + } + + private packageJsonChanged(folder: Uri) { + delete this.folderToPackageJsonInfo[folder.toString()]; + const str = folder.toString().toLowerCase(); + workspace.textDocuments.filter(document => this.getUriFolder(document.uri).toString().toLowerCase() === str) + .forEach(document => this.queueReadme(document)); + } + + private getUriFolder(uri: Uri) { + return uri.with({ path: path.posix.dirname(uri.path) }); + } + + private addDiagnostics(diagnostics: Diagnostic[], document: TextDocument, begin: number, end: number, src: string, context: Context, info: PackageJsonInfo) { + const uri = Uri.parse(src); + const scheme = uri.scheme.toLowerCase(); + + if (scheme && scheme !== 'https' && scheme !== 'data') { + const range = new Range(document.positionAt(begin), document.positionAt(end)); + diagnostics.push(new Diagnostic(range, httpsRequired, DiagnosticSeverity.Warning)); + } + + if (scheme === 'data') { + const range = new Range(document.positionAt(begin), document.positionAt(end)); + diagnostics.push(new Diagnostic(range, dataUrlsNotValid, DiagnosticSeverity.Warning)); + } + + if (!scheme && !info.hasHttpsRepository) { + const range = new Range(document.positionAt(begin), document.positionAt(end)); + diagnostics.push(new Diagnostic(range, relativeUrlRequiresHttpsRepository, DiagnosticSeverity.Warning)); + } + + if (endsWith(uri.path.toLowerCase(), '.svg') && allowedBadgeProviders.indexOf(uri.authority.toLowerCase()) === -1) { + const range = new Range(document.positionAt(begin), document.positionAt(end)); + diagnostics.push(new Diagnostic(range, svgsNotValid, DiagnosticSeverity.Warning)); + } + } + + private clear(document: TextDocument) { + this.diagnosticsCollection.delete(document.uri); + this.packageJsonQ.delete(document); + } + + public dispose() { + this.disposables.forEach(d => d.dispose()); + this.disposables = []; + } +} + +function endsWith(haystack: string, needle: string): boolean { + let diff = haystack.length - needle.length; + if (diff > 0) { + return haystack.indexOf(needle, diff) === diff; + } else if (diff === 0) { + return haystack === needle; + } else { + return false; + } +} + +function fileExists(path: string): Promise { + return new Promise((resolve, reject) => { + fs.lstat(path, (err, stats) => { + if (!err) { + resolve(true); + } else if (err.code === 'ENOENT') { + resolve(false); + } else { + reject(err); + } + }); + }); +} diff --git a/extensions/extension-editing/src/typings/ref.d.ts b/extensions/extension-editing/src/typings/ref.d.ts index bc057c55878..216911a680e 100644 --- a/extensions/extension-editing/src/typings/ref.d.ts +++ b/extensions/extension-editing/src/typings/ref.d.ts @@ -4,4 +4,3 @@ *--------------------------------------------------------------------------------------------*/ /// -/// -- GitLab