diff --git a/actions-languageservice/src/complete.test.ts b/actions-languageservice/src/complete.test.ts index b9c20ca..b18949e 100644 --- a/actions-languageservice/src/complete.test.ts +++ b/actions-languageservice/src/complete.test.ts @@ -373,4 +373,51 @@ jobs: end: {line: 6, character: 17} }); }); + + describe("completes with indentation", () => { + it("default indentation", async () => { + const input = `on: push +jobs: + build: + step|`; + const result = await complete(...getPositionFromCursor(input)); + + // Sequence + expect(result.filter(x => x.label === "steps").map(x => x.textEdit?.newText)).toEqual(["steps:\n - "]); + + // Mapping + expect(result.filter(x => x.label === "env").map(x => x.textEdit?.newText)).toEqual(["env:\n "]); + + // Value + expect(result.filter(x => x.label === "timeout-minutes").map(x => x.textEdit?.newText)).toEqual([ + "timeout-minutes: " + ]); + + // One-of + expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency"]); + }); + + it("custom indentation", async () => { + // Use 3 spaces to indent + const input = `on: push +jobs: + build: + step|`; + const result = await complete(...getPositionFromCursor(input)); + + // Sequence + expect(result.filter(x => x.label === "steps").map(x => x.textEdit?.newText)).toEqual(["steps:\n - "]); + + // Mapping + expect(result.filter(x => x.label === "env").map(x => x.textEdit?.newText)).toEqual(["env:\n "]); + + // Value + expect(result.filter(x => x.label === "timeout-minutes").map(x => x.textEdit?.newText)).toEqual([ + "timeout-minutes: " + ]); + + // One-of + expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency"]); + }); + }); }); diff --git a/actions-languageservice/src/complete.ts b/actions-languageservice/src/complete.ts index 0477b19..1dfe643 100644 --- a/actions-languageservice/src/complete.ts +++ b/actions-languageservice/src/complete.ts @@ -16,6 +16,7 @@ import {getContext, Mode} from "./context-providers/default"; import {getWorkflowContext, WorkflowContext} from "./context/workflow-context"; import {nullTrace} from "./nulltrace"; import {findToken} from "./utils/find-token"; +import {guessIndentation} from "./utils/indentation-guesser"; import {mapRange} from "./utils/range"; import {transform} from "./utils/transform"; import {Value, ValueProviderConfig} from "./value-providers/config"; @@ -94,18 +95,23 @@ export async function complete( } } - const values = await getValues(token, keyToken, parent, valueProviderConfig, workflowContext); + const indentation = guessIndentation(newDoc, 2, true); // Use 2 spaces as default and most common for YAML + const indentString = " ".repeat(indentation.tabSize); + + const values = await getValues(token, keyToken, parent, valueProviderConfig, workflowContext, indentString); let replaceRange: Range | undefined; if (token?.range) { replaceRange = mapRange(token.range); } return values.map(value => { + const newText = value.insertText || value.label; + const item: CompletionItem = { label: value.label, detail: value.description, tags: value.deprecated ? [CompletionItemTag.Deprecated] : undefined, - textEdit: replaceRange ? TextEdit.replace(replaceRange, value.label) : undefined + textEdit: replaceRange ? TextEdit.replace(replaceRange, newText) : TextEdit.insert(position, newText) }; return item; @@ -117,7 +123,8 @@ async function getValues( keyToken: TemplateToken | null, parent: TemplateToken | null, valueProviderConfig: ValueProviderConfig | undefined, - workflowContext: WorkflowContext + workflowContext: WorkflowContext, + indentation: string ): Promise { if (!parent) { return []; @@ -150,7 +157,7 @@ async function getValues( return []; } - const values = definitionValues(def); + const values = definitionValues(def, indentation); return filterAndSortCompletionOptions(values, existingValues); } diff --git a/actions-languageservice/src/utils/indentation-guesser.ts b/actions-languageservice/src/utils/indentation-guesser.ts new file mode 100644 index 0000000..4f527a9 --- /dev/null +++ b/actions-languageservice/src/utils/indentation-guesser.ts @@ -0,0 +1,269 @@ +// Copied from https://github.com/microsoft/vscode/blob/c3b617f9b058e01cbc5cf00ec3813a7047326503/src/vs/editor/common/model/indentationGuesser.ts +// And adapted to work with languageserver-textdocument types + +import {TextDocument} from "vscode-languageserver-textdocument"; + +enum CharCode { + Space = 32, + Tab = 9, + Comma = 44 +} + +function getLineContent(doc: TextDocument, lineNumber: number): string { + return doc.getText({ + start: { + line: lineNumber - 1, + character: 0 + }, + end: { + line: lineNumber - 1, + character: Number.MAX_SAFE_INTEGER + } + }); +} + +function getLineCharCode(doc: TextDocument, lineNumber: number, index: number): number { + return doc + .getText({ + start: { + line: lineNumber - 1, + character: index + }, + end: { + line: lineNumber - 1, + character: index + 1 + } + }) + .charCodeAt(0); +} + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +class SpacesDiffResult { + public spacesDiff: number = 0; + public looksLikeAlignment: boolean = false; +} + +/** + * Compute the diff in spaces between two line's indentation. + */ +function spacesDiff(a: string, aLength: number, b: string, bLength: number, result: SpacesDiffResult): void { + result.spacesDiff = 0; + result.looksLikeAlignment = false; + + // This can go both ways (e.g.): + // - a: "\t" + // - b: "\t " + // => This should count 1 tab and 4 spaces + + let i: number; + + for (i = 0; i < aLength && i < bLength; i++) { + const aCharCode = a.charCodeAt(i); + const bCharCode = b.charCodeAt(i); + + if (aCharCode !== bCharCode) { + break; + } + } + + let aSpacesCnt = 0, + aTabsCount = 0; + for (let j = i; j < aLength; j++) { + const aCharCode = a.charCodeAt(j); + if (aCharCode === CharCode.Space) { + aSpacesCnt++; + } else { + aTabsCount++; + } + } + + let bSpacesCnt = 0, + bTabsCount = 0; + for (let j = i; j < bLength; j++) { + const bCharCode = b.charCodeAt(j); + if (bCharCode === CharCode.Space) { + bSpacesCnt++; + } else { + bTabsCount++; + } + } + + if (aSpacesCnt > 0 && aTabsCount > 0) { + return; + } + if (bSpacesCnt > 0 && bTabsCount > 0) { + return; + } + + const tabsDiff = Math.abs(aTabsCount - bTabsCount); + const spacesDiff = Math.abs(aSpacesCnt - bSpacesCnt); + + if (tabsDiff === 0) { + // check if the indentation difference might be caused by alignment reasons + // sometime folks like to align their code, but this should not be used as a hint + result.spacesDiff = spacesDiff; + + if (spacesDiff > 0 && 0 <= bSpacesCnt - 1 && bSpacesCnt - 1 < a.length && bSpacesCnt < b.length) { + if (b.charCodeAt(bSpacesCnt) !== CharCode.Space && a.charCodeAt(bSpacesCnt - 1) === CharCode.Space) { + if (a.charCodeAt(a.length - 1) === CharCode.Comma) { + // This looks like an alignment desire: e.g. + // const a = b + c, + // d = b - c; + result.looksLikeAlignment = true; + } + } + } + return; + } + if (spacesDiff % tabsDiff === 0) { + result.spacesDiff = spacesDiff / tabsDiff; + return; + } +} + +/** + * Result for a guessIndentation + */ +export interface IGuessedIndentation { + /** + * If indentation is based on spaces (`insertSpaces` = true), then what is the number of spaces that make an indent? + */ + tabSize: number; + /** + * Is indentation based on spaces? + */ + insertSpaces: boolean; +} + +export function guessIndentation( + source: TextDocument, + defaultTabSize: number, + defaultInsertSpaces: boolean +): IGuessedIndentation { + // Look at most at the first 10k lines + const linesCount = Math.min(source.lineCount, 10000); + + let linesIndentedWithTabsCount = 0; // number of lines that contain at least one tab in indentation + let linesIndentedWithSpacesCount = 0; // number of lines that contain only spaces in indentation + + let previousLineText = ""; // content of latest line that contained non-whitespace chars + let previousLineIndentation = 0; // index at which latest line contained the first non-whitespace char + + const ALLOWED_TAB_SIZE_GUESSES = [2, 4, 6, 8, 3, 5, 7]; // prefer even guesses for `tabSize`, limit to [2, 8]. + const MAX_ALLOWED_TAB_SIZE_GUESS = 8; // max(ALLOWED_TAB_SIZE_GUESSES) = 8 + + const spacesDiffCount = [0, 0, 0, 0, 0, 0, 0, 0, 0]; // `tabSize` scores + const tmp = new SpacesDiffResult(); + + for (let lineNumber = 1; lineNumber <= linesCount; lineNumber++) { + const currentLineText = getLineContent(source, lineNumber); + const currentLineLength = currentLineText.length; + + // if the text buffer is chunk based, so long lines are cons-string, v8 will flattern the string when we check charCode. + // checking charCode on chunks directly is cheaper. + const useCurrentLineText = currentLineLength <= 65536; + + let currentLineHasContent = false; // does `currentLineText` contain non-whitespace chars + let currentLineIndentation = 0; // index at which `currentLineText` contains the first non-whitespace char + let currentLineSpacesCount = 0; // count of spaces found in `currentLineText` indentation + let currentLineTabsCount = 0; // count of tabs found in `currentLineText` indentation + for (let j = 0, lenJ = currentLineLength; j < lenJ; j++) { + const charCode = useCurrentLineText ? currentLineText.charCodeAt(j) : getLineCharCode(source, lineNumber, j); + + if (charCode === CharCode.Tab) { + currentLineTabsCount++; + } else if (charCode === CharCode.Space) { + currentLineSpacesCount++; + } else { + // Hit non whitespace character on this line + currentLineHasContent = true; + currentLineIndentation = j; + break; + } + } + + // Ignore empty or only whitespace lines + if (!currentLineHasContent) { + continue; + } + + if (currentLineTabsCount > 0) { + linesIndentedWithTabsCount++; + } else if (currentLineSpacesCount > 1) { + linesIndentedWithSpacesCount++; + } + + spacesDiff(previousLineText, previousLineIndentation, currentLineText, currentLineIndentation, tmp); + + if (tmp.looksLikeAlignment) { + // if defaultInsertSpaces === true && the spaces count == tabSize, we may want to count it as valid indentation + // + // - item1 + // - item2 + // + // otherwise skip this line entirely + // + // const a = 1, + // b = 2; + + if (!(defaultInsertSpaces && defaultTabSize === tmp.spacesDiff)) { + continue; + } + } + + const currentSpacesDiff = tmp.spacesDiff; + if (currentSpacesDiff <= MAX_ALLOWED_TAB_SIZE_GUESS) { + spacesDiffCount[currentSpacesDiff]++; + } + + previousLineText = currentLineText; + previousLineIndentation = currentLineIndentation; + } + + let insertSpaces = defaultInsertSpaces; + if (linesIndentedWithTabsCount !== linesIndentedWithSpacesCount) { + insertSpaces = linesIndentedWithTabsCount < linesIndentedWithSpacesCount; + } + + let tabSize = defaultTabSize; + + // Guess tabSize only if inserting spaces... + if (insertSpaces) { + let tabSizeScore = insertSpaces ? 0 : 0.1 * linesCount; + + // console.log("score threshold: " + tabSizeScore); + + ALLOWED_TAB_SIZE_GUESSES.forEach(possibleTabSize => { + const possibleTabSizeScore = spacesDiffCount[possibleTabSize]; + if (possibleTabSizeScore > tabSizeScore) { + tabSizeScore = possibleTabSizeScore; + tabSize = possibleTabSize; + } + }); + + // Let a tabSize of 2 win even if it is not the maximum + // (only in case 4 was guessed) + if ( + tabSize === 4 && + spacesDiffCount[4] > 0 && + spacesDiffCount[2] > 0 && + spacesDiffCount[2] >= spacesDiffCount[4] / 2 + ) { + tabSize = 2; + } + } + + // console.log('--------------------------'); + // console.log('linesIndentedWithTabsCount: ' + linesIndentedWithTabsCount + ', linesIndentedWithSpacesCount: ' + linesIndentedWithSpacesCount); + // console.log('spacesDiffCount: ' + spacesDiffCount); + // console.log('tabSize: ' + tabSize + ', tabSizeScore: ' + tabSizeScore); + + return { + insertSpaces: insertSpaces, + tabSize: tabSize + }; +} diff --git a/actions-languageservice/src/value-providers/config.ts b/actions-languageservice/src/value-providers/config.ts index 725ad4c..9172cc4 100644 --- a/actions-languageservice/src/value-providers/config.ts +++ b/actions-languageservice/src/value-providers/config.ts @@ -4,6 +4,7 @@ export interface Value { label: string; description?: string; deprecated?: boolean; + insertText?: string; } export enum ValueProviderKind { diff --git a/actions-languageservice/src/value-providers/definition.ts b/actions-languageservice/src/value-providers/definition.ts index 81aaf6a..16ca421 100644 --- a/actions-languageservice/src/value-providers/definition.ts +++ b/actions-languageservice/src/value-providers/definition.ts @@ -1,5 +1,6 @@ import {BooleanDefinition} from "@github/actions-workflow-parser/templates/schema/boolean-definition"; import {Definition} from "@github/actions-workflow-parser/templates/schema/definition"; +import {DefinitionType} from "@github/actions-workflow-parser/templates/schema/definition-type"; import {MappingDefinition} from "@github/actions-workflow-parser/templates/schema/mapping-definition"; import {OneOfDefinition} from "@github/actions-workflow-parser/templates/schema/one-of-definition"; import {SequenceDefinition} from "@github/actions-workflow-parser/templates/schema/sequence-definition"; @@ -8,15 +9,15 @@ import {getWorkflowSchema} from "@github/actions-workflow-parser/workflows/workf import {Value} from "./config"; import {stringsToValues} from "./strings-to-values"; -export function definitionValues(def: Definition): Value[] { +export function definitionValues(def: Definition, indentation: string): Value[] { const schema = getWorkflowSchema(); if (def instanceof MappingDefinition) { - return mappingValues(def, schema.definitions); + return mappingValues(def, schema.definitions, indentation); } if (def instanceof OneOfDefinition) { - return oneOfValues(def, schema.definitions); + return oneOfValues(def, schema.definitions, indentation); } if (def instanceof BooleanDefinition) { @@ -35,30 +36,64 @@ export function definitionValues(def: Definition): Value[] { if (def instanceof SequenceDefinition) { const itemDef = schema.getDefinition(def.itemType); if (itemDef) { - return definitionValues(itemDef); + return definitionValues(itemDef, indentation); } } return []; } -function mappingValues(mappingDefinition: MappingDefinition, definitions: {[key: string]: Definition}): Value[] { +function mappingValues( + mappingDefinition: MappingDefinition, + definitions: {[key: string]: Definition}, + indentation: string +): Value[] { const properties: Value[] = []; for (const [key, value] of Object.entries(mappingDefinition.properties)) { + let insertText: string | undefined; + let description: string | undefined; if (value.type) { const typeDef = definitions[value.type]; description = typeDef?.description; + + if (typeDef) { + switch (typeDef.definitionType) { + case DefinitionType.Sequence: + insertText = `${key}:\n${indentation}- `; + break; + + case DefinitionType.Mapping: + insertText = `${key}:\n${indentation}`; + break; + + case DefinitionType.OneOf: + // No special insertText in this case + break; + + default: + insertText = `${key}: `; + } + } } - properties.push({label: key, description: description}); + + properties.push({ + label: key, + description, + insertText + }); } return properties; } -function oneOfValues(oneOfDefinition: OneOfDefinition, definitions: {[key: string]: Definition}): Value[] { +function oneOfValues( + oneOfDefinition: OneOfDefinition, + definitions: {[key: string]: Definition}, + indentation: string +): Value[] { const values: Value[] = []; for (const key of oneOfDefinition.oneOf) { - values.push(...definitionValues(definitions[key])); + values.push(...definitionValues(definitions[key], indentation)); } return distinctValues(values); }