diff --git a/src/server.ts b/src/server.ts index 525504c..2b291df 100644 --- a/src/server.ts +++ b/src/server.ts @@ -15,6 +15,7 @@ import { FoldingService } from "./services/folding"; import { RenamingService } from "./services/renaming"; import { FindReferencesService } from "./services/find-references"; import { GoToDefinitionService } from "./services/go-to-definition"; +import { FormattingService } from "./services/formatting"; export interface LanguageService { activate(connection: IConnection, documents: TextDocuments): void; @@ -29,6 +30,7 @@ const services: ReadonlyArray = [ new DiagnosticsService(), new FindReferencesService(), new FoldingService(), + new FormattingService(), new GoToDefinitionService(), new RenamingService(), ]; diff --git a/src/services/formatting.ts b/src/services/formatting.ts new file mode 100644 index 0000000..e23d51f --- /dev/null +++ b/src/services/formatting.ts @@ -0,0 +1,125 @@ +/*! + * Copyright 2019 Omar Tawfik. Please see LICENSE file at the root of this repository. + */ + +import { IConnection, TextDocuments, ServerCapabilities, TextEdit, Range } from "vscode-languageserver"; +import { LanguageService } from "../server"; +import { accessCache } from "../util/cache"; +import { Compilation } from "../util/compilation"; +import { Token, TokenKind } from "../scanning/tokens"; + +export class FormattingService implements LanguageService { + public fillCapabilities(capabilities: ServerCapabilities): void { + capabilities.documentFormattingProvider = true; + } + + public activate(connection: IConnection, documents: TextDocuments): void { + connection.onDocumentFormatting(params => { + const { uri } = params.textDocument; + const compilation = accessCache(documents, uri); + + const { insertSpaces, tabSize } = params.options; + const indentation = insertSpaces ? " ".repeat(tabSize) : "\t"; + + const result = FormattingService.format(compilation, indentation); + const fullRange = Range.create(0, 0, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER); + return [TextEdit.replace(fullRange, result)]; + }); + } + + public static format(compilation: Compilation, indentationValue: string): string { + const lines = Array(); + let indentationLevel = 0; + + for (const token of compilation.tokens) { + const lineIndex = token.range.start.line; + while (lines.length < lineIndex) { + lines.push(new FormattingLine(indentationLevel)); + } + + switch (token.kind) { + case TokenKind.RightCurlyBracket: + case TokenKind.RightSquareBracket: { + indentationLevel -= 1; + break; + } + } + + if (lines.length === lineIndex) { + lines.push(new FormattingLine(indentationLevel)); + } + + lines[lineIndex].add(token); + + switch (token.kind) { + case TokenKind.LeftCurlyBracket: + case TokenKind.LeftSquareBracket: { + indentationLevel += 1; + break; + } + } + } + + lines.push(new FormattingLine(0)); + return lines.map(line => line.format(indentationValue)).join(compilation.text.includes("\r\n") ? "\r\n" : "\n"); + } +} + +class FormattingLine { + private readonly tokens = Array(); + + public constructor(private readonly indentationLevel: number) {} + + public add(token: Token): void { + this.tokens.push(token); + } + + public format(indentationValue: string): string { + let result = indentationValue.repeat(this.indentationLevel); + + function append(value: string): void { + if (value.trim() || result.trim()) { + result += value; + } + } + + for (const token of this.tokens) { + switch (token.kind) { + case TokenKind.VersionKeyword: + case TokenKind.WorkflowKeyword: + case TokenKind.ActionKeyword: + case TokenKind.OnKeyword: + case TokenKind.ResolvesKeyword: + case TokenKind.UsesKeyword: + case TokenKind.NeedsKeyword: + case TokenKind.RunsKeyword: + case TokenKind.ArgsKeyword: + case TokenKind.EnvKeyword: + case TokenKind.SecretsKeyword: + case TokenKind.Equal: + case TokenKind.Identifier: + case TokenKind.IntegerLiteral: + case TokenKind.StringLiteral: + case TokenKind.Unrecognized: + case TokenKind.Comment: + case TokenKind.LeftCurlyBracket: + case TokenKind.LeftSquareBracket: + case TokenKind.RightCurlyBracket: + case TokenKind.RightSquareBracket: { + append(" "); + append(token.text); + break; + } + case TokenKind.Comma: { + append(token.text); + break; + } + default: { + throw new Error(`Unexpected token kind '${token.kind}'`); + } + } + } + + return result.trimRight(); + } +} diff --git a/test/formatting.spec.ts b/test/formatting.spec.ts new file mode 100644 index 0000000..97add8d --- /dev/null +++ b/test/formatting.spec.ts @@ -0,0 +1,73 @@ +/*! + * Copyright 2019 Omar Tawfik. Please see LICENSE file at the root of this repository. + */ + +import { Compilation } from "../src/util/compilation"; +import { FormattingService } from "../src/services/formatting"; + +describe(__filename, () => { + it("formats a one line empty action", () => { + expectFormatting(` +action"x"{} +`).toMatchInlineSnapshot(` +" +action \\"x\\" { } +" +`); + }); + + it("formats a one line action with properties", () => { + expectFormatting(` +action "x" { uses="./ci" } +`).toMatchInlineSnapshot(` +" +action \\"x\\" { uses = \\"./ci\\" } +" +`); + }); + + it("formats an action with embedded comments", () => { + expectFormatting(` +# on start of line + # should be aligned to start +action "x" { + # should be indented + uses="./ci" // should be at end + } +`).toMatchInlineSnapshot(` +" +# on start of line +# should be aligned to start +action \\"x\\" { + # should be indented + uses = \\"./ci\\" // should be at end +} +" +`); + }); + + it("formats a file with unrecognized characters", () => { + expectFormatting(` +# on start of line + # should be aligned to start + version =$ +`).toMatchInlineSnapshot(` +" +# on start of line +# should be aligned to start +version = $ +" +`); + }); +}); + +function expectFormatting(text: string): jest.Matchers { + const compilation = new Compilation(text); + const result = FormattingService.format(compilation, " "); + + const secondCompilation = new Compilation(result); + const secondResult = FormattingService.format(secondCompilation, " "); + expect(result).toBe(secondResult); + + return expect(result); +}