@microsoft/compose-language-service
Version:
Language service for Docker Compose documents
172 lines • 8.87 kB
JavaScript
;
/*!--------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See LICENSE in the project root for license information.
*--------------------------------------------------------------------------------------------*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.ImageLinkProvider = void 0;
exports.parseImageRef = parseImageRef;
const vscode_languageserver_1 = require("vscode-languageserver");
const yaml_1 = require("yaml");
const ActionContext_1 = require("../utils/ActionContext");
const yamlRangeToLspRange_1 = require("../utils/yamlRangeToLspRange");
const ProviderBase_1 = require("./ProviderBase");
// A path component (registry hostname, namespace segment, or repository name).
// Hostnames may include dots; repository names may include dots, underscores, dashes.
const PATH_COMPONENT_REGEX = /^[\w][.\w-]*$/;
const TAG_REGEX = /^[.\w-]+$/;
const DIGEST_REGEX = /^[\w.+-]+:[0-9a-f]+$/i;
// Docker Hub's explicit registry hostnames. A reference using any of these is treated
// the same as a bare Docker Hub reference (e.g. `docker.io/library/alpine` === `library/alpine`).
const DOCKER_HUB_REGISTRIES = new Set(['docker.io', 'index.docker.io', 'registry-1.docker.io']);
/**
* A path component before the first `/` is treated as a registry hostname iff it
* contains a `.` (domain), contains a `:` (host:port), is exactly `localhost`, or
* contains an uppercase character (repository namespaces are always lowercase, so an
* uppercase first component signals a registry host). This matches Docker's reference
* resolution rules.
* @see https://github.com/distribution/reference
*/
function isRegistryHost(component) {
return component.includes('.') || component.includes(':') || component === 'localhost' || component !== component.toLowerCase();
}
function parseImageRef(image) {
if (!image || /\s/.test(image)) {
return undefined;
}
// Split off an optional @digest suffix first (e.g. `@sha256:...`). The digest never
// forms part of the link target, so it is validated then dropped.
const atIndex = image.indexOf('@');
const digest = atIndex >= 0 ? image.substring(atIndex + 1) : undefined;
const ref = atIndex >= 0 ? image.substring(0, atIndex) : image;
if (digest !== undefined && !DIGEST_REGEX.test(digest)) {
return undefined;
}
// Split off optional :tag — the tag is the suffix after the last ':' in the
// final '/'-separated component (so a ':' inside a registry host:port portion
// is not mistaken for a tag separator).
const lastSlash = ref.lastIndexOf('/');
const nameAndTag = lastSlash >= 0 ? ref.substring(lastSlash + 1) : ref;
const pathPrefix = lastSlash >= 0 ? ref.substring(0, lastSlash) : '';
const tagColon = nameAndTag.indexOf(':');
const imageName = tagColon >= 0 ? nameAndTag.substring(0, tagColon) : nameAndTag;
const tag = tagColon >= 0 ? nameAndTag.substring(tagColon + 1) : undefined;
if (!PATH_COMPONENT_REGEX.test(imageName)) {
return undefined;
}
if (tag !== undefined && !TAG_REGEX.test(tag)) {
return undefined;
}
// The link covers the registry/namespace/name path only — excluding both the
// :tag and @digest suffixes.
const pathLength = ref.length - (tag !== undefined ? tag.length + 1 : 0);
const pathParts = pathPrefix ? pathPrefix.split('/') : [];
// Reject path components that don't look like valid hostname/namespace segments.
// Registry hosts can include `:` (for port); namespace segments cannot.
for (let i = 0; i < pathParts.length; i++) {
const part = pathParts[i];
const allowColon = i === 0 && isRegistryHost(part);
const partRegex = allowColon ? /^[\w][.\w:-]*$/ : PATH_COMPONENT_REGEX;
if (!partRegex.test(part)) {
return undefined;
}
}
let registry;
let namespace;
if (pathParts.length === 0) {
// Plain `alpine` — Docker Hub official image.
}
else if (isRegistryHost(pathParts[0])) {
registry = pathParts[0];
if (pathParts.length > 1) {
namespace = pathParts.slice(1).join('/');
}
}
else {
// No explicit registry — everything is a Docker Hub namespace path.
// Docker Hub only supports a single namespace level (`user/repo`), so reject deeper paths.
if (pathParts.length > 1) {
return undefined;
}
namespace = pathParts[0];
}
return { registry, namespace, imageName, tag, pathLength };
}
function buildLinkUri(ref, imageTypes) {
// Registry hostnames are case-insensitive; normalize before matching known registries.
const registry = ref.registry?.toLowerCase();
// Docker Hub — either no registry hostname in the reference, or one of Docker Hub's
// explicit hostnames. Docker Hub only supports a single namespace level, so skip deeper paths.
if (registry === undefined || DOCKER_HUB_REGISTRIES.has(registry)) {
if (ref.namespace === undefined) {
imageTypes.add('dockerHub');
return `https://hub.docker.com/_/${ref.imageName}`;
}
if (ref.namespace.includes('/')) {
return undefined;
}
imageTypes.add('dockerHubNamespaced');
return `https://hub.docker.com/r/${ref.namespace}/${ref.imageName}`;
}
// Microsoft Container Registry — images are mirrored to a Docker Hub page
// under the `microsoft-<namespace>-<name>` convention.
if (registry === 'mcr.microsoft.com' && ref.namespace !== undefined) {
imageTypes.add('mcr');
return `https://hub.docker.com/_/microsoft-${ref.namespace.replace(/\//g, '-')}-${ref.imageName}`;
}
// GitHub Container Registry — link to the package page on github.com.
// Only the `ghcr.io/<owner>/<package>` form maps cleanly; deeper paths are skipped.
if (registry === 'ghcr.io' && ref.namespace !== undefined && !ref.namespace.includes('/')) {
imageTypes.add('ghcr');
return `https://github.com/${ref.namespace}/${ref.imageName}/pkgs/container/${ref.imageName}`;
}
// Quay.io — link to the public repository page.
if (registry === 'quay.io' && ref.namespace !== undefined && !ref.namespace.includes('/')) {
imageTypes.add('quay');
return `https://quay.io/repository/${ref.namespace}/${ref.imageName}`;
}
return undefined;
}
class ImageLinkProvider extends ProviderBase_1.ProviderBase {
on(params, token) {
const ctx = (0, ActionContext_1.getCurrentContext)();
ctx.telemetry.properties.isActivationEvent = 'true'; // This happens automatically so we'll treat it as isActivationEvent === true
const results = [];
const imageTypes = new Set();
const serviceMap = params.document.yamlDocument.value.getIn(['services']);
if ((0, yaml_1.isMap)(serviceMap)) {
for (const service of serviceMap.items) {
// Within each loop we'll check for cancellation (though this is expected to be very fast)
if (token.isCancellationRequested) {
return Promise.resolve(undefined);
}
if ((0, yaml_1.isMap)(service.value)) {
const image = service.value.getIn(['image'], true);
const hasBuild = service.value.has('build');
if (!hasBuild && (0, yaml_1.isScalar)(image) && typeof image.value === 'string') {
const quoteOffset = (image.type === yaml_1.Scalar.QUOTE_SINGLE || image.type === yaml_1.Scalar.QUOTE_DOUBLE) ? 1 : 0; // Offset if the scalar is quoted
const link = ImageLinkProvider.getLinkForImage(image.value, imageTypes);
if (link && image.range) {
results.push(vscode_languageserver_1.DocumentLink.create((0, yamlRangeToLspRange_1.yamlRangeToLspRange)(params.document.textDocument, [quoteOffset + image.range[0] + link.start, quoteOffset + image.range[0] + link.start + link.length]), link.uri));
}
}
}
}
}
ctx.telemetry.properties.imageTypes = Array.from(imageTypes.values()).sort().join(',');
return Promise.resolve(results);
}
static getLinkForImage(image, imageTypes) {
const ref = parseImageRef(image);
if (!ref) {
return undefined;
}
const uri = buildLinkUri(ref, imageTypes);
if (!uri) {
return undefined;
}
return { uri, start: 0, length: ref.pathLength };
}
}
exports.ImageLinkProvider = ImageLinkProvider;
//# sourceMappingURL=ImageLinkProvider.js.map