diff --git a/CHANGELOG.md b/CHANGELOG.md index bac819a..7b2cd97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog All notable changes to this project will be documented in this file. +## [Unreleased] +### Added +- add formatting and validation to the docker-langserver binary ([#79](https://github.com/rcjsuen/dockerfile-language-server-nodejs/issues/79)) + ## [0.0.10] - 2017-10-23 ### Added - textDocument/codeAction diff --git a/FORMATTER.md b/FORMATTER.md new file mode 100644 index 0000000..284eddb --- /dev/null +++ b/FORMATTER.md @@ -0,0 +1,63 @@ +# Formatting Dockerfiles + +The formatter included will perform the following operations on a Dockerfile: + +- remove leading whitespace if it begins with an instruction +- remove trailing whitespace if and only if the line only contains whitespace characters +- take escaped newlines into account and indent Dockerfile instructions that span multiple lines + +## Command Line Interface + +The formatter used by the language server can be run standalone from the CLI. +If no file is specified, the CLI will attempt to format the contents of a file named `Dockerfile` in the current working directory if it exists. + +### Help +``` +> docker-langserver format --help +Usage: docker-langserver format [options] [file] + +Options: + + -h, --help Output usage information + -s, --spaces Format with the of spaces + -t, --tabs Format with tabs +``` + +### Example +```Dockerfile + FROM node +HEALTHCHECK --interva=30s CMD ls + RUN "echo" ls \ + + "echoS"sdfdf \ + asdfasdf + copy . . +ADD app.zip +CMD ls +``` +#### Formatting with Tabs +``` +> docker-langserver format -t +FROM node +HEALTHCHECK --interva=30s CMD ls +RUN "echo" ls \ + + "echoS"sdfdf \ + asdfasdf +copy . . +ADD app.zip +CMD ls +``` +#### Formatting with Spaces +``` +> docker-langserver format -s 5 +FROM node +HEALTHCHECK --interva=30s CMD ls +RUN "echo" ls \ + + "echoS"sdfdf \ + asdfasdf +copy . . +ADD app.zip +CMD ls +``` diff --git a/README.md b/README.md index d428035..a6a1b18 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,12 @@ Supported features: - code actions - code completion - definition -- diagnostics +- [diagnostics](https://github.com/rcjsuen/dockerfile-language-server-nodejs/blob/master/VALIDATOR.md) +(can be used from the command line) - document highlight - document symbols -- formatting +- [formatting](https://github.com/rcjsuen/dockerfile-language-server-nodejs/blob/master/FORMATTER.md) +(can be used from the command line) - hovers - rename - signature help @@ -55,9 +57,29 @@ the desired method of communciating with the language server via one of the three arguments shown below. ``` -docker-langserver --node-ipc -docker-langserver --stdio -docker-langserver --socket= +docker-langserver listen --node-ipc +docker-langserver listen --stdio +docker-langserver listen --socket= +``` + +The installed binary is not only used for launching the language server. It can +also be used to [format](https://github.com/rcjsuen/dockerfile-language-server-nodejs/blob/master/FORMATTER.md) +and [validate](https://github.com/rcjsuen/dockerfile-language-server-nodejs/blob/master/VALIDATOR.md) +Dockerfiles as a standalone command line application. +``` +> docker-langserver --help +Usage: docker-langserver [] + +Options: + + -h, --help Output usage information + -v, --version Output version information + +Commands: + + listen Start the language server + format Format a Dockerfile + lint Validate a Dockerfile ``` ### Settings diff --git a/VALIDATOR.md b/VALIDATOR.md new file mode 100644 index 0000000..4553fbc --- /dev/null +++ b/VALIDATOR.md @@ -0,0 +1,133 @@ +# Dockerfile Validation + +The validator provided by the langauge server is intended to perform the same kinds of validation that the Docker builder performs when building an image. +Any errors that are generated by the validator should also be generated by the Docker builder (albeit the message strings itself may differ). +If the validator generates an error for a Dockerfile that the Docker builder is able to build, that is considered to be a bug. + +However, errors that the Docker builder generates may not necessarily be replicated by the validator due to difficulties in verifying the validity of a given Dockerfile instruction's arguments given the build context and other factors. + +The currently supported version of the Docker builder is **Docker CE 17.09 [2017-09-26]** + +## Command Line Interface + +The validator used by the language server can be run standalone from the CLI. +If no file is specified, the CLI will attempt to validate the contents of a file named `Dockerfile` in the current working directory if it exists. + +### Help +``` +> docker-langserver lint --help +Usage: docker-langserver lint [options] [file] + +Options: + + -h, --help Output usage information + -j, --json Output in JSON format +``` + +### Example +```Dockerfile + FROM node +HEALTHCHECK --interva=30s CMD ls + RUN "echo" ls \ + + "echoS"sdfdf \ + asdfasdf + copy . . +ADD app.zip +CMD ls +``` +#### CLI Output +``` +> docker-langserver lint +Line: 2 +HEALTHCHECK --interva=30s CMD ls + ^^^^^^^^^ +Error: Unknown flag: interva + +Line: 4 +Warning: Empty continuation line + +Line: 7 + copy . . + ^^^^ +Warning: Instructions should be written in uppercase letters + +Line: 8 +ADD app.zip + ^^^^^^^ +Error: ADD requires at least two arguments +``` +#### JSON Output +``` +> docker-langserver lint -j +[{"range":{"start":{"line":1,"character":12},"end":{"line":1,"character":21}},"message":"Unknown flag: interva","severity":"error"},{"range":{"start":{"line":3,"character":0},"end":{"line":4,"character":0}},"message":"Empty continuation line","severity":"warning"},{"range":{"start":{"line":6,"character":2},"end":{"line":6,"character":6}},"message":"Instructions should be written in uppercase letters","severity":"warning"},{"range":{"start":{"line":7,"character":4},"end":{"line":7,"character":11}},"message":"ADD requires at least two arguments","severity":"error"}] +``` + +## Supported Validation Checks + +### General +#### Instructions +- instructions should be written in uppercase +- instruction has no arguments +- instruction has an insufficient number of arguments +- unknown instruction detected +- duplicate instruction flags detected +- unknown instruction flag detected +- instruction flag has no value defined +#### Directives +- invalid value specified for `escape` parser directive +- directives should be written in lowercase. +#### Others +- empty continuation lines + +### CMD +- multiple `CMD` instructions detected + +### ENTRYPOINT +- multiple `ENTRYPOINT` instructions detected + +### ENV +- syntax missing equals sign '`=`' +- syntax missing single quote '`'`' +- syntax missing double quotes '`"`' +- property has no name + +### EXPOSE +- invalid container port specified +- invalid protocol specified + +### FROM +- `FROM` instruction not found at the beginning of the Dockerfile +- invalid build stage name specified +- duplicate build stage name detected +- second argument detected but not an `AS` + +### HEALTHCHECK +- `CMD` form has no arguments +- `NONE` form has arguments defined +- type that is not `CMD` or `NONE` detected +- `--retries` flag has invalid syntax +- `--retries` value is not at least one +- duration of `--interval`, `--start-period`, or `--timeout` is invalid +- duration of `--interval`, `--start-period`, or `--timeout` is less than one millisecond +- duration of `--interval`, `--start-period`, or `--timeout` has an unknown unit of time specified +- multiple `HEALTHCHECK` instructions detected + +### LABEL +- syntax missing equals sign '`=`' +- syntax missing single quote '`'`' +- syntax missing double quotes '`"`' +- property has no name + +### MAINTAINER +- use of deprecated instruction detected + +### ONBUILD +- can't chain `ONBUILD` instruction with `ONBUILD ONBUILD` +- invalid `ONBUILD` trigger instruction + +### SHELL +- `SHELL` not written in JSON form + +### STOPSIGNAL +- invalid stop signal diff --git a/bin/docker-langserver b/bin/docker-langserver index f881b79..b50b34b 100644 --- a/bin/docker-langserver +++ b/bin/docker-langserver @@ -3,4 +3,256 @@ * Copyright (c) Remy Suen. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. * ------------------------------------------------------------------------------------------ */ -require("../lib/server"); +const fs = require("fs"); + +let args = process.argv.slice(2); +if (args.length === 0 || args[0] === "-h" || args[0] === "--help") { + printHelp(); +} else if (args[0] === "-v" || args[0] === "--version") { + printVersion(); +} else if (args[0] === "format") { + let options = parseFormattingOptions(process.argv.slice(3)); + let formatter = require("../lib/dockerFormatter"); + let dockerFormatter = new formatter.DockerFormatter(); + let formatted = dockerFormatter.formatFile(options.path, options); + console.log(formatted); +} else if (args[0] === "lint") { + let json = false; + let path = "Dockerfile"; + if (args.length > 1) { + if (args[1] === "-h" || args[1] === "--help") { + printLintHelp(); + process.exit(0); + } else if (args[1] === "-j" || args[1] === "--json") { + json = true; + if (args.length > 2) { + path = args[2]; + } + } else { + path = args[1]; + } + } + checkPath(path); + let validator = require("../lib/dockerValidator"); + let vscodeLangserver = require("vscode-languageserver-types"); + let content = fs.readFileSync(path).toString(); + let document = vscodeLangserver.TextDocument.create("", "", 0, content); + let dockerValidator = new validator.Validator(); + let diagnostics = dockerValidator.validate(document); + + let exitCode = 0; + for (let i = 0; i < diagnostics.length; i++) { + if (diagnostics[i].severity === vscodeLangserver.DiagnosticSeverity.Error) { + exitCode = 1; + } + } + if (json) { + for (let i = 0; i < diagnostics.length; i++) { + delete diagnostics[i].code; + delete diagnostics[i].source; + if (diagnostics[i].severity === vscodeLangserver.DiagnosticSeverity.Warning) { + diagnostics[i].severity = "warning"; + } else if (diagnostics[i].severity === vscodeLangserver.DiagnosticSeverity.Error) { + diagnostics[i].severity = "error"; + } + } + console.log(JSON.stringify(diagnostics)); + process.exit(exitCode); + } + for (let i = 0; i < diagnostics.length; i++) { + let message = "Warning: "; + if (diagnostics[i].severity === vscodeLangserver.DiagnosticSeverity.Error) { + message = "Error: "; + } + message = message + diagnostics[i].message; + if (diagnostics[i].code === validator.ValidationCode.EMPTY_CONTINUATION_LINE) { + // empty continuation lines encountered, just print out the line + // numbers and ignore the content (if any) + for (let j = diagnostics[i].range.start.line + 1; j < diagnostics[i].range.end.line + 1; j++) { + console.log("Line: " + j); + console.log(message); + console.log(); + } + } else if (diagnostics[i].range.start.line === diagnostics[i].range.end.line) { + let rangeOffsetStart = document.offsetAt(diagnostics[i].range.start); + let rangeOffsetEnd = document.offsetAt(diagnostics[i].range.end); + let x = document.offsetAt({ line: diagnostics[i].range.start.line, character: 0 }); + let y = document.offsetAt({ line: diagnostics[i].range.end.line + 1, character: 0 }) - 1; + let highlight = ""; + let spacing = rangeOffsetStart - x; + for (let j = 0; j < spacing; j++) { + highlight += " "; + } + spacing = rangeOffsetEnd - rangeOffsetStart; + for (let j = 0; j < spacing; j++) { + highlight += "^"; + } + console.log("Line: " + (diagnostics[i].range.start.line + 1)); + console.log(content.substring(x, y)); + console.log(highlight); + console.log(message); + console.log(); + } else { + // the diagnostic spans multiple lines + let rangeOffsetStart = document.offsetAt(diagnostics[i].range.start); + let rangeOffsetEnd = document.offsetAt(diagnostics[i].range.end); + console.log("Line: " + (diagnostics[i].range.start.line + 1) + "-" + (diagnostics[i].range.end.line + 1)); + for (let j = diagnostics[i].range.start.line; j < diagnostics[i].range.end.line + 1; j++) { + let x = document.offsetAt({ line: j, character: 0 }); + let y = document.offsetAt({ line: j + 1, character: 0 }) - 1; + if (content.charAt(y - 1) === '\r') { + y--; + } + let highlight = ""; + if (x <= rangeOffsetStart && y <= rangeOffsetEnd) { + let spacing = rangeOffsetStart - x; + for (let j = 0; j < spacing; j++) { + highlight += " "; + } + spacing = y - rangeOffsetStart; + for (let j = 0; j < spacing; j++) { + highlight += "^"; + } + } else if (rangeOffsetStart <= x && y <= rangeOffsetEnd) { + let spacing = y - x; + for (let j = 0; j < spacing; j++) { + highlight += "^"; + } + } else { + let spacing = rangeOffsetEnd - x; + for (let j = 0; j < spacing; j++) { + highlight += "^"; + } + } + console.log(content.substring(x, y)); + console.log(highlight); + } + + console.log(message); + console.log(); + } + } + process.exit(exitCode); +} else if (args[0] === "listen") { + if (args[1] === "-h" || args[1] === "--help" || args.length === 1) { + printListenHelp(); + process.exit(0); + } else { + require("../lib/server"); + } +} else { + require("../lib/server"); +} + +function printVersion() { + console.log("docker-langserver version 0.0.11 (supports Docker CE 17.09)"); +} + +function printHelp() { + console.log("Usage: docker-langserver []"); + console.log(); + console.log("Options:"); + console.log(); + console.log(" -h, --help Output usage information"); + console.log(" -v, --version Output version information"); + console.log(); + console.log("Commands:"); + console.log(); + console.log(" listen Start the language server"); + console.log(" format Format a Dockerfile"); + console.log(" lint Validate a Dockerfile"); + console.log(); +} + +function printFormatHelp() { + console.log("Usage: docker-langserver format [options] [file]"); + console.log(); + console.log("Options:"); + console.log(); + console.log(" -h, --help Output usage information"); + console.log(" -s, --spaces Format with the of spaces"); + console.log(" -t, --tabs Format with tabs"); + console.log(); +} + +function printLintHelp() { + console.log("Usage: docker-langserver lint [options] [file]"); + console.log(); + console.log("Options:"); + console.log(); + console.log(" -h, --help Output usage information"); + console.log(" -j, --json Output in JSON format"); + console.log(); +} + +function printListenHelp() { + console.log("Usage: docker-langserver listen "); + console.log(); + console.log("Options:"); + console.log(); + console.log(" -h, --help Output usage information"); + console.log(" --node-ipc Listen for messages using Node IPC"); + console.log(" --socket= Connect the language server to the specified port"); + console.log(" --stdio Read and write messages to and from stdio"); + console.log(); +} + +function parseFormattingOptions(args) { + const options = { + tabSize: 4, + insertSpaces: false + } + + if (args[0] === "-h" || args[0] === "--help") { + printFormatHelp(); + process.exit(0); + } else if (args[0] === "-s" || args[0] === "--spaces") { + if (args.length < 2) { + console.log("Tab size unspecified"); + console.log(); + printFormatHelp(); + process.exit(1); + } + const tabSize = parseInt(args[1]); + if (isNaN(tabSize)) { + console.log("Invalid number specified: " + args[1]); + console.log(); + printFormatHelp(); + process.exit(1); + } else if (tabSize < 0) { + console.log("Negative number specified: " + args[1]); + console.log(); + printFormatHelp(); + process.exit(1); + } + options.tabSize = tabSize; + options.insertSpaces = true; + + options.path = args[2]; + } else if (args[0] === "-t" || args[0] === "--tabs") { + options.path = args[1]; + } else { + options.path = args[0]; + } + + if (!options.path) { + // no path specified, set to "Dockerfile" + options.path = "Dockerfile"; + } + + checkPath(options.path); + return options; +} + +function checkPath(path) { + if (!fs.existsSync(path)) { + // nothing found at the given path + console.log("File not found: " + path); + process.exit(1); + } + if (fs.statSync(path).isDirectory()) { + // specified path was a directory + console.log("Directory found: " + path); + process.exit(1); + } +} diff --git a/src/dockerFormatter.ts b/src/dockerFormatter.ts index 9f2708e..b660bdc 100644 --- a/src/dockerFormatter.ts +++ b/src/dockerFormatter.ts @@ -4,6 +4,7 @@ * ------------------------------------------------------------------------------------------ */ 'use strict'; +import * as fs from 'fs'; import { TextDocument, TextEdit, Position, Range, FormattingOptions, } from 'vscode-languageserver'; @@ -11,6 +12,21 @@ import { DockerfileParser } from './parser/dockerfileParser'; export class DockerFormatter { + public formatFile(path: string, formattingOptions: FormattingOptions): string { + let content = fs.readFileSync(path).toString(); + const document = TextDocument.create(null, null, 0, content); + const edits = this.formatDocument(document, formattingOptions); + // the returned edits are ordered based on their positions in the document, + // by iterating and applying the edits to the content backwards through the + // array, the offsets will not become stale and no recalculations will be necessary + for (let i = edits.length - 1; i >= 0; i--) { + const start = document.offsetAt(edits[i].range.start); + const end = document.offsetAt(edits[i].range.end); + content = content.substring(0, start) + edits[i].newText + content.substring(end); + } + return content; + } + private getIndentation(formattingOptions?: FormattingOptions): string { let indentation = "\t"; if (formattingOptions && formattingOptions.insertSpaces) { diff --git a/src/dockerValidator.ts b/src/dockerValidator.ts index 6a5dff5..2c8058f 100644 --- a/src/dockerValidator.ts +++ b/src/dockerValidator.ts @@ -2,6 +2,7 @@ * Copyright (c) Remy Suen. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. * ------------------------------------------------------------------------------------------ */ +import * as fs from 'fs'; import { TextDocument, Range, Position, Diagnostic, DiagnosticSeverity } from 'vscode-languageserver'; @@ -169,6 +170,12 @@ export class Validator { } } + public validateFile(path: string): Diagnostic[] { + let content = fs.readFileSync(path).toString(); + const document = TextDocument.create(null, null, 0, content); + return this.validate(document); + } + validate(document: TextDocument): Diagnostic[] { this.document = document; let problems: Diagnostic[] = [];