From 6580300e263fbd24af2c75a8e0447bfaa8d36ff5 Mon Sep 17 00:00:00 2001 From: Stephen Weatherford Date: Sat, 29 Aug 2020 15:48:47 -0700 Subject: [PATCH 1/8] Add bail after first failure flag --- .vscode/launch.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.vscode/launch.json b/.vscode/launch.json index 9264923ed..6a0fbfc45 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -45,6 +45,7 @@ "env": { "MOCHA_grep": "", // RegExp of tests to run (case-insensitve, set to empty for all) "MOCHA_invert": "0", // Invert the RegExp + "MOCHA_bail": "0", // Bail after first failure "AZCODE_ARM_IGNORE_BUNDLE": "1", "MOCHA_enableTimeouts": "0", // Disable time-outs "DEBUGTELEMETRY": "1", // 1=quiet; verbose=see telemetry in console; 0=send telemetry @@ -99,6 +100,7 @@ "env": { "MOCHA_grep": "", // RegExp of tests to run (empty for all) "MOCHA_invert": "0", // Invert the RegExp + "MOCHA_bail": "0", // Bail after first failure "MOCHA_enableTimeouts": "0", // Disable time-outs "DEBUGTELEMETRY": "1", // 1=quiet; verbose=see telemetry in console; 0=send telemetry "NODE_DEBUG": "", From b89a04f8eb61b6b1bb05d3eee5b5874782f6170a Mon Sep 17 00:00:00 2001 From: Stephen Weatherford Date: Mon, 10 Aug 2020 17:01:19 -0700 Subject: [PATCH 2/8] dependsOn completions, part 1 --- extension.bundle.ts | 10 +- .../positionContexts/PositionContext.ts | 72 +- .../TemplatePositionContext.ts | 69 +- .../templates/DeploymentTemplateDoc.ts | 4 +- src/documents/templates/IResource.ts | 1 + src/documents/templates/Resource.ts | 7 + .../templates/getDependsOnCompletions.ts | 120 +++ .../templates/getResourceIdCompletions.ts | 61 +- src/documents/templates/getResourcesInfo.ts | 180 +++- src/language/json/JSON.ts | 3 + src/snippets/InsertionContext.ts | 2 +- src/snippets/SnippetManager.ts | 2 +- src/snippets/showInsertionContext.ts | 2 +- src/util/debugMarkStrings.ts | 4 +- src/util/strings.ts | 12 + src/vscodeIntegration/Completion.ts | 3 + .../toVsCodeCompletionItem.ts | 78 +- test/dependsOn.completions.test.ts | 959 ++++++++++++++++++ test/support/assertEx.ts | 57 ++ test/templates/ResourceInfo.test.ts | 386 +++++++ 20 files changed, 1882 insertions(+), 150 deletions(-) create mode 100644 src/documents/templates/getDependsOnCompletions.ts create mode 100644 test/dependsOn.completions.test.ts create mode 100644 test/support/assertEx.ts create mode 100644 test/templates/ResourceInfo.test.ts diff --git a/extension.bundle.ts b/extension.bundle.ts index 6aa25438f..601d878b0 100644 --- a/extension.bundle.ts +++ b/extension.bundle.ts @@ -2,9 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See LICENSE.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ - // tslint:disable: no-consecutive-blank-lines // Format-on-save tends to add an extra line to the end of this file - /** * This is the external face of extension.bundle.js, the main webpack bundle for the extension. * Anything needing to be exposed outside of the extension sources must be exported from here, because @@ -17,11 +15,9 @@ * The tests should import '../extension.bundle.ts'. At design-time they live in tests/ and so will pick up this file (extension.bundle.ts). * At runtime the tests live in dist/tests and will therefore pick up the main webpack bundle at dist/extension.bundle.js. */ - import * as TLE from "./src/language/expressions/TLE"; import * as Json from "./src/language/json/JSON"; import * as basic from "./src/language/json/Tokenizer"; -import * as Language from "./src/language/LineColPos"; import * as Completion from './src/vscodeIntegration/Completion'; export { activateInternal, deactivateInternal } from './src/AzureRMTools'; // Export activate/deactivate for main.js @@ -43,7 +39,7 @@ export { ParameterDefinitionCodeLens, ShowCurrentParameterFileCodeLens } from ". export { DeploymentTemplateDoc } from "./src/documents/templates/DeploymentTemplateDoc"; export { ExpressionType } from "./src/documents/templates/ExpressionType"; export { looksLikeResourceTypeStringLiteral } from "./src/documents/templates/getResourceIdCompletions"; -export { splitResourceNameIntoSegments } from "./src/documents/templates/getResourcesInfo"; +export { getResourcesInfo, IResourceInfo, ResourceInfo, splitResourceNameIntoSegments } from "./src/documents/templates/getResourcesInfo"; export { InsertItem } from "./src/documents/templates/insertItem"; export { TemplateScope, TemplateScopeKind } from "./src/documents/templates/scopes/TemplateScope"; export * from "./src/documents/templates/scopes/templateScopes"; @@ -58,7 +54,7 @@ export { FunctionSignatureHelp, TleParseResult } from "./src/language/expression export { DefinitionKind, INamedDefinition } from "./src/language/INamedDefinition"; export { Issue } from "./src/language/Issue"; export { IssueKind } from "./src/language/IssueKind"; -export * from "./src/language/LineColPos"; +export { LineColPos } from "./src/language/LineColPos"; export { ReferenceList } from "./src/language/ReferenceList"; export { ContainsBehavior, Span } from "./src/language/Span"; export { LanguageServerState } from "./src/languageclient/startArmLanguageServer"; @@ -93,14 +89,12 @@ export { UndefinedVariablePropertyVisitor } from "./src/visitors/UndefinedVariab export { UnrecognizedBuiltinFunctionIssue, UnrecognizedUserFunctionIssue, UnrecognizedUserNamespaceIssue } from "./src/visitors/UnrecognizedFunctionIssues"; export { UnrecognizedFunctionVisitor } from "./src/visitors/UnrecognizedFunctionVisitor"; export { IGotoParameterValueArgs } from "./src/vscodeIntegration/commandArguments"; -export * from "./src/vscodeIntegration/Completion"; export { IConfiguration } from "./src/vscodeIntegration/Configuration"; export { HoverInfo } from "./src/vscodeIntegration/Hover"; export { JsonOutlineProvider, shortenTreeLabel } from "./src/vscodeIntegration/Treeview"; export { getVSCodePositionFromPosition, getVSCodeRangeFromSpan } from "./src/vscodeIntegration/vscodePosition"; export { Completion }; export { Json }; -export { Language }; export { basic }; export { TLE }; diff --git a/src/documents/positionContexts/PositionContext.ts b/src/documents/positionContexts/PositionContext.ts index 07d6bd442..d2fa0f1b0 100644 --- a/src/documents/positionContexts/PositionContext.ts +++ b/src/documents/positionContexts/PositionContext.ts @@ -261,17 +261,24 @@ export abstract class PositionContext { * the span of insertion, etc.) */ // tslint:disable-next-line: max-func-body-length cyclomatic-complexity - public getInsertionContext(triggerCharacter: string | undefined): InsertionContext { + public getInsertionContext(options: { triggerCharacter?: string; allowInsideJsonString?: boolean }): InsertionContext { + const triggerCharacter = options.triggerCharacter; + const allowInsideJsonString = !!options.allowInsideJsonString; + if (!this.document.topLevelValue) { // Empty JSON document return { context: KnownContexts.emptyDocument, parents: [] }; } - let replacementInfo = this.getCompletionReplacementSpanInfo(); - const insideDoubleQuotes = replacementInfo.token?.type === Json.TokenType.QuotedString; + const insideJsonString = this.jsonToken?.type === Json.TokenType.QuotedString; let parents: (Json.ArrayValue | Json.ObjectValue | Json.Property)[] = []; - const insertionParent: Json.ArrayValue | Json.ObjectValue | undefined = this.getInsertionParent(); + let insertionParent: Json.ArrayValue | Json.ObjectValue | undefined = this.getInsertionParent(); + if (insideJsonString && !insertionParent && allowInsideJsonString) { + const pcAtStartOfString = this.document.getContextFromDocumentCharacterIndex(this.jsonTokenStartIndex, this._associatedDocument); + insertionParent = pcAtStartOfString.getInsertionParent(); + } + if (insertionParent) { const lineage: (Json.ArrayValue | Json.ObjectValue | Json.Property)[] | undefined = this.document.topLevelValue.findLineage(insertionParent); assert(lineage, `Couldn't find JSON value inside the top-level value: ${insertionParent.toFullFriendlyString()}`); @@ -296,13 +303,16 @@ export abstract class PositionContext { // - context is "parameters" // } // - const parentPropertyName = parents[1].propertyName; - return { context: parentPropertyName, parents, insideDoubleQuotes }; + const parentPropertyName = parents[1].propertyName?.toLowerCase(); + return { context: parentPropertyName, parents, insideJsonString }; } if ( - !insideDoubleQuotes - && !triggerCharacter + ( + (insideJsonString && allowInsideJsonString) + || !insideJsonString + ) + && (!triggerCharacter || triggerCharacter === '"') && parents[0] instanceof Json.ArrayValue && parents[1] instanceof Json.Property ) { @@ -315,12 +325,12 @@ export abstract class PositionContext { // - context is "resources" // ] // - const parentPropertyName = parents[1].propertyName; - return { context: parentPropertyName, parents }; + const parentPropertyName = parents[1].propertyName?.toLowerCase(); + return { context: parentPropertyName, parents, insideJsonString }; } if ( - !insideDoubleQuotes + !insideJsonString // Don't currently need the insideJsonString=true case here && parents[0] instanceof Json.ObjectValue && parents[1] instanceof Json.ArrayValue && parents[2] instanceof Json.Property @@ -345,7 +355,8 @@ export abstract class PositionContext { return { context: undefined, triggerSuggest: true, - parents + parents, + insideJsonString }; } else if (!triggerCharacter) { // Inside an empty object inside an array (likely because of the above case but could be manually triggered @@ -360,21 +371,29 @@ export abstract class PositionContext { // } // ] // - const parentPropertyName = parents[2].propertyName; + const parentPropertyName = parents[2].propertyName?.toLowerCase(); return { context: parentPropertyName, curlyBraces: insertionParent.span, - parents + parents, + insideJsonString }; } } } - return { context: undefined, parents, insideDoubleQuotes }; + return { context: undefined, parents, insideJsonString }; + } + + public get isInsideComment(): boolean { + return !!this.document.jsonParseResult.getCommentTokenAtDocumentIndex( + this.documentCharacterIndex, + ContainsBehavior.enclosed); } // Retrieves the array or object which would be the parent if a JSON item were added - // at the current location + // at the current location. If the cursor is inside any other kind of value, or inside a comment, + // returns undefined. public getInsertionParent(): Json.ObjectValue | Json.ArrayValue | undefined { const enclosingJsonValue = this.document.jsonParseResult.getValueAtCharacterIndex( this.documentCharacterIndex, @@ -385,14 +404,27 @@ export abstract class PositionContext { // We're immediately inside an object or array, not any other kind of value. But we could still be inside // a comment - if (!!this.document.jsonParseResult.getCommentTokenAtDocumentIndex( - this.documentCharacterIndex, - ContainsBehavior.enclosed) - ) { + if (this.isInsideComment) { // Inside a comment, can't insert here! return undefined; } return enclosingJsonValue; } + + /** + * Gets the nearest enclosing parent (array or object) at the current position + */ + public getEnclosingParent(): Json.ArrayValue | Json.ObjectValue | undefined { + const enclosingJsonValue = this.document.jsonParseResult.getValueAtCharacterIndex( + this.documentCharacterIndex, + ContainsBehavior.enclosed); + if (enclosingJsonValue) { + const lineage: (Json.ArrayValue | Json.ObjectValue | Json.Property)[] | undefined = this.document.topLevelValue?.findLineage(enclosingJsonValue) ?? []; + const lineageWithoutProperties = <(Json.ArrayValue | Json.ObjectValue)[]>lineage.filter(l => !(l instanceof Json.Property)); + return lineageWithoutProperties[lineage.length - 1]; + } + + return undefined; + } } diff --git a/src/documents/positionContexts/TemplatePositionContext.ts b/src/documents/positionContexts/TemplatePositionContext.ts index efa84a322..28c91227d 100644 --- a/src/documents/positionContexts/TemplatePositionContext.ts +++ b/src/documents/positionContexts/TemplatePositionContext.ts @@ -1,9 +1,7 @@ // ---------------------------------------------------------------------------- // Copyright (c) Microsoft Corporation. All rights reserved. // ---------------------------------------------------------------------------- - // tslint:disable:max-line-length - import { templateKeys } from "../../constants"; import { ext } from "../../extensionVariables"; import { assert } from '../../fixed_assert'; @@ -21,6 +19,7 @@ import { DeploymentParametersDoc } from "../parameters/DeploymentParametersDoc"; import { IParameterDefinition } from "../parameters/IParameterDefinition"; import { getPropertyValueCompletionItems } from "../parameters/ParameterValues"; import { DeploymentTemplateDoc } from "../templates/DeploymentTemplateDoc"; +import { getDependsOnCompletions } from "../templates/getDependsOnCompletions"; import { getResourceIdCompletions } from "../templates/getResourceIdCompletions"; import { IFunctionMetadata, IFunctionParameterMetadata } from "../templates/IFunctionMetadata"; import { TemplateScope } from "../templates/scopes/TemplateScope"; @@ -287,8 +286,8 @@ export class TemplatePositionContext extends PositionContext { return false; } - public getInsertionContext(triggerCharacter: string | undefined): InsertionContext { - const insertionContext = super.getInsertionContext(triggerCharacter); + public getSnippetInsertionContext(options: { triggerCharacter?: string; allowInsideJsonString?: boolean }): InsertionContext { + const insertionContext = super.getInsertionContext(options); const context = insertionContext.context; const parents = insertionContext.parents; @@ -304,7 +303,7 @@ export class TemplatePositionContext extends PositionContext { insertionContext.context = KnownContexts.userFuncParameterDefinitions; } } else if ( - (triggerCharacter === undefined || triggerCharacter === '"') + (options.triggerCharacter === undefined || options.triggerCharacter === '"') && this.isInsideResourceBody(parents) ) { insertionContext.context = KnownContexts.resourceBody; @@ -315,7 +314,7 @@ export class TemplatePositionContext extends PositionContext { } public async getCompletionItems(triggerCharacter: string | undefined): Promise { - const tleInfo = this.tleInfo; + const tleInfo = this.tleInfo; // << BREAKPOINT HERE (TWO) const completions: Completion.Item[] = []; for (let uniqueScope of this.document.uniqueScopes) { @@ -329,7 +328,7 @@ export class TemplatePositionContext extends PositionContext { } if (!tleInfo) { - // No TLE string at this location, consider snippet completions + // No JSON string at this location, consider snippet completions const snippets = await this.getSnippetCompletionItems(triggerCharacter); if (snippets.triggerSuggest) { return snippets; @@ -337,9 +336,6 @@ export class TemplatePositionContext extends PositionContext { completions.push(...snippets.items); } } else { - - // We're inside a JSON string. It may or may not contain square brackets. - // The function/string/number/etc at the current position inside the string expression, // or else the JSON string itself even it's not an expression const tleValue: TLE.Value | undefined = tleInfo.tleValue; @@ -366,11 +362,62 @@ export class TemplatePositionContext extends PositionContext { } } + completions.push(...this.getDependsOnCompletionItems(triggerCharacter)); + return { items: completions }; } + /** + * Gets the scope at the current position + */ + public getScope(): TemplateScope { + if (this.jsonValue && this.document.topLevelValue) { + const objectLineage = <(Json.ObjectValue | Json.ArrayValue)[]>this.document.topLevelValue + ?.findLineage(this.jsonValue) + ?.filter(v => v instanceof Json.ObjectValue); + const scopes = this.document.allScopes; // Note: not unique because resources are unique even when scope is not (see CONSIDER in TemplateScope.ts) + for (const parent of objectLineage.reverse()) { + const innermostMachingScope = scopes.find(s => s.rootObject === parent); + if (innermostMachingScope) { + return innermostMachingScope; + } + } + } + + return this.document.topLevelScope; + } + + /** + * Gets the nearest resource object that contains the current position + */ + public getEnclosingResource(): TemplateScope { + if (this.jsonValue && this.document.topLevelValue) { + const objectLineage = <(Json.ObjectValue | Json.ArrayValue)[]>this.document.topLevelValue + ?.findLineage(this.jsonValue) + ?.filter(v => v instanceof Json.ObjectValue); + const scopes = this.document.allScopes; // Note: Use all scopes because resources are unique even when scope is not (see CONSIDER in TemplateScope.ts) + for (const parent of objectLineage.reverse()) { + const innermostMachingScope = scopes.find(s => s.rootObject === parent); + if (innermostMachingScope) { + return innermostMachingScope; + } + } + } + + return this.document.topLevelScope; + } + + private getDependsOnCompletionItems(triggerCharacter: string | undefined): Completion.Item[] { + const insertionContext = this.getSnippetInsertionContext({ triggerCharacter, allowInsideJsonString: true }); + if (insertionContext.context === 'dependson') { + return getDependsOnCompletions(this); + } + + return []; + } + private async getSnippetCompletionItems(triggerCharacter: string | undefined): Promise { - const insertionContext = this.getInsertionContext(triggerCharacter); + const insertionContext = this.getSnippetInsertionContext({ triggerCharacter }); if (insertionContext.triggerSuggest) { return { items: [], triggerSuggest: true }; } else if (insertionContext.context) { diff --git a/src/documents/templates/DeploymentTemplateDoc.ts b/src/documents/templates/DeploymentTemplateDoc.ts index 1e27bc129..a83f84f41 100644 --- a/src/documents/templates/DeploymentTemplateDoc.ts +++ b/src/documents/templates/DeploymentTemplateDoc.ts @@ -400,11 +400,11 @@ export class DeploymentTemplateDoc extends DeploymentDocument { //#endregion - public getContextFromDocumentLineAndColumnIndexes(documentLineIndex: number, documentColumnIndex: number, associatedParameters: DeploymentDocument | undefined, allowOutOfBounds: boolean = false): TemplatePositionContext { + public getContextFromDocumentLineAndColumnIndexes(documentLineIndex: number, documentColumnIndex: number, associatedParameters: DeploymentDocument | undefined, allowOutOfBounds: boolean = true): TemplatePositionContext { return TemplatePositionContext.fromDocumentLineAndColumnIndexes(this, documentLineIndex, documentColumnIndex, expectParameterDocumentOrUndefined(associatedParameters), allowOutOfBounds); } - public getContextFromDocumentCharacterIndex(documentCharacterIndex: number, associatedParameters: DeploymentDocument | undefined, allowOutOfBounds: boolean = false): TemplatePositionContext { + public getContextFromDocumentCharacterIndex(documentCharacterIndex: number, associatedParameters: DeploymentDocument | undefined, allowOutOfBounds: boolean = true): TemplatePositionContext { return TemplatePositionContext.fromDocumentCharacterIndex(this, documentCharacterIndex, expectParameterDocumentOrUndefined(associatedParameters), allowOutOfBounds); } diff --git a/src/documents/templates/IResource.ts b/src/documents/templates/IResource.ts index 340fda527..79cbba863 100644 --- a/src/documents/templates/IResource.ts +++ b/src/documents/templates/IResource.ts @@ -21,4 +21,5 @@ export interface IResource { * The nameValue of the resource object in the JSON */ nameValue: Json.StringValue | undefined; + resourceTypeValue: Json.StringValue | undefined; } diff --git a/src/documents/templates/Resource.ts b/src/documents/templates/Resource.ts index 649edec84..ef877f73f 100644 --- a/src/documents/templates/Resource.ts +++ b/src/documents/templates/Resource.ts @@ -16,6 +16,7 @@ import { getChildTemplateForResourceObject } from "./scopes/templateScopes"; export class Resource implements IResource { private readonly _childTemplate: CachedValue = new CachedValue(); private readonly _nameValueCache: CachedValue = new CachedValue(); + private readonly _resTypeCache: CachedValue = new CachedValue(); constructor( private readonly parentScope: TemplateScope, @@ -44,4 +45,10 @@ export class Resource implements IResource { this.resourceObject.getPropertyValue(templateKeys.resourceName)?.asStringValue ); } + + public get resourceTypeValue(): Json.StringValue | undefined { + return this._resTypeCache.getOrCacheValue(() => + this.resourceObject.getPropertyValue(templateKeys.resourceType)?.asStringValue + ); + } } diff --git a/src/documents/templates/getDependsOnCompletions.ts b/src/documents/templates/getDependsOnCompletions.ts new file mode 100644 index 000000000..685cbfeb0 --- /dev/null +++ b/src/documents/templates/getDependsOnCompletions.ts @@ -0,0 +1,120 @@ +import { MarkdownString } from "vscode"; +import { assert } from "../../fixed_assert"; +import * as Json from "../../language/json/JSON"; +import { ContainsBehavior, Span } from "../../language/Span"; +import { removeSingleQuotes } from "../../util/strings"; +import * as Completion from "../../vscodeIntegration/Completion"; +import { TemplatePositionContext } from "../positionContexts/TemplatePositionContext"; +import { getResourcesInfo, IJsonResourceInfo, IResourceInfo } from "./getResourcesInfo"; + +// Handle completions for dependsOn array entries +export function getDependsOnCompletions( + pc: TemplatePositionContext +): Completion.Item[] { + const completions: Completion.Item[] = []; + let span: Span; + if (pc.jsonToken?.type === Json.TokenType.QuotedString) { + // We're already inside a JSON string. The completion should replace the entire string because it's + // not likely the user would want anything else + span = pc.jsonToken.span; + } else { + span = pc.emptySpanAtDocumentCharacterIndex; + } + + const scope = pc.getScope(); + const infos = getResourcesInfo(scope); + if (infos.length === 0) { + return completions; + } + + // Find the nearest IResource containing the current position + let currentResource = findClosestEnclosingResource(pc.documentCharacterIndex, infos); + + // Find descendents of the resource + const descendentsIncludingSelf: Set = findDescendentsIncludingSelf(currentResource); + + for (const resource of infos) { + // Don't offer completion from a resource to itself or to any direct descendent + if (descendentsIncludingSelf.has(resource)) { + continue; + } + + const item = getDependsOnCompletion(resource, span); + if (item) { + completions.push(item); + } + } + + return completions; +} + +/** + * Get possible completions for entries inside a resource's "dependsOn" array + */ +function getDependsOnCompletion(resource: IResourceInfo, span: Span): Completion.Item | undefined { + const resourceIdExpression = resource.getResourceIdExpression(); + const shortNameExpression = resource.shortNameExpression; + + if (shortNameExpression && resourceIdExpression) { + const label = shortNameExpression; + const insertText = `"[${resourceIdExpression}]"`; + const detail = removeSingleQuotes(resource.getFullTypeExpression() ?? ''); + const resourceResTypeMarkdown = `- **Name**: *${resource.getFullNameExpression()}*\n- **Type**: *${resource.getFullTypeExpression()}*`; + const longDocumentation = `Reference to resource\n${resourceResTypeMarkdown}`; + + const item = new Completion.Item({ + label, + insertText: insertText, + detail, + documentation: new MarkdownString(`${insertText}\n\n${longDocumentation}`), + span, + kind: Completion.CompletionKind.dependsOnResourceId, + // Normally vscode uses label if this isn't specified, but it doesn't seem to like the "[" in the label, + // so specify filter text explicitly + filterText: insertText + }); + + return item; + } + + return undefined; +} + +function findClosestEnclosingResource(documentIndex: number, infos: IJsonResourceInfo[]): IJsonResourceInfo | undefined { + const containsBehavior = ContainsBehavior.enclosed; + + // Find any resource that contains the point + const firstMatch = infos.find(info => info.resourceObject.span.contains(documentIndex, containsBehavior)); + if (firstMatch) { + // We found an arbitrary resource that contains this position. Find the deepest child that still contains it + let deepestMatch = firstMatch; + // tslint:disable-next-line: no-constant-condition + while (true) { + const childrenContainingResource = deepestMatch.children.filter(child => (child).resourceObject.span.contains(documentIndex, containsBehavior)); + assert(childrenContainingResource.length <= 1, "Shouldn't find multiple children containing the document position"); + if (childrenContainingResource.length > 0) { + deepestMatch = childrenContainingResource[0]; + } else { + return deepestMatch; + } + } + } + + return undefined; +} + +function findDescendentsIncludingSelf(resource: IResourceInfo | undefined): Set { + const descendentsIncludingSelf: Set = new Set(); + visit(resource); + return descendentsIncludingSelf; + + function visit(res: IResourceInfo | undefined): void { + if (res) { + descendentsIncludingSelf.add(res); + + for (const child of res.children) { + visit(child); + } + } + } +} diff --git a/src/documents/templates/getResourceIdCompletions.ts b/src/documents/templates/getResourceIdCompletions.ts index 1fb95b9c0..8e62023d6 100644 --- a/src/documents/templates/getResourceIdCompletions.ts +++ b/src/documents/templates/getResourceIdCompletions.ts @@ -111,7 +111,7 @@ function getCompletions( // Add completions for all remaining resources, at the given name segment index const results: Completion.Item[] = []; for (let info of filteredResources) { - const insertText = info.nameExpressions[nameSegmentIndex]; + const insertText = info.nameSegmentExpressions[nameSegmentIndex]; if (insertText) { const label = insertText; @@ -164,10 +164,11 @@ function findFunctionCallArgumentWithResourceType( } let argTextLC = argText?.toLowerCase(); - for (let info of resources) { - const resFullType = info.fullTypeName; - if (resFullType.toLowerCase() === argTextLC) { - return { argIndex, typeExpression: argText }; + if (argTextLC) { + for (let info of resources) { + if (info.getFullTypeExpression()?.toLowerCase() === argTextLC) { + return { argIndex, typeExpression: argText }; + } } } } @@ -181,7 +182,7 @@ function filterResourceInfosByType(infos: IResourceInfo[], typeExpression: strin return []; } const typeExpressionLC = typeExpression.toLowerCase(); - return infos.filter(info => info.fullTypeName.toLowerCase() === typeExpressionLC); + return infos.filter(info => info.getFullTypeExpression()?.toLowerCase() === typeExpressionLC); } function filterResourceInfosByNameSegment(infos: IResourceInfo[], segmentExpression: string, segmentIndex: number): IResourceInfo[] { @@ -189,7 +190,7 @@ function filterResourceInfosByNameSegment(infos: IResourceInfo[], segmentExpress return []; } const segmentExpressionLC = lowerCaseAndNoWhitespace(segmentExpression); - return infos.filter(info => lowerCaseAndNoWhitespace(info.nameExpressions[segmentIndex]) === segmentExpressionLC); + return infos.filter(info => lowerCaseAndNoWhitespace(info.nameSegmentExpressions[segmentIndex]) === segmentExpressionLC); } function lowerCaseAndNoWhitespace(s: string | undefined): string | undefined { @@ -216,28 +217,30 @@ function getResourceTypeCompletions( const results: Completion.Item[] = []; for (let info of getResourcesInfo(scope)) { - const insertText = info.fullTypeName; - const label = insertText; - let typeArgument = funcCall.argumentExpressions[argumentIndex]; - let span = getReplacementSpan(pc, typeArgument, parentStringToken); - - results.push(new Completion.Item({ - label, - insertText, - span, - kind: Completion.CompletionKind.tleResourceIdResTypeParameter, - priority: Completion.CompletionPriority.high, - // Force the first of the resourceId completions to be preselected, otherwise - // vscode tends to preselect one of the regular function completions based - // on recently-typed text - preselect: true, - commitCharacters: [','], - telemetryProperties: { - segment: String(0), - arg: String(argumentIndex), - function: funcCall.fullName - } - })); + const insertText = info.getFullTypeExpression(); + if (insertText) { + const label = insertText; + let typeArgument = funcCall.argumentExpressions[argumentIndex]; + let span = getReplacementSpan(pc, typeArgument, parentStringToken); + + results.push(new Completion.Item({ + label, + insertText, + span, + kind: Completion.CompletionKind.tleResourceIdResTypeParameter, + priority: Completion.CompletionPriority.high, + // Force the first of the resourceId completions to be preselected, otherwise + // vscode tends to preselect one of the regular function completions based + // on recently-typed text + preselect: true, + commitCharacters: [','], + telemetryProperties: { + segment: String(0), + arg: String(argumentIndex), + function: funcCall.fullName + } + })); + } } return Completion.Item.dedupeByLabel(results); diff --git a/src/documents/templates/getResourcesInfo.ts b/src/documents/templates/getResourcesInfo.ts index f07cd1572..1874f8255 100644 --- a/src/documents/templates/getResourcesInfo.ts +++ b/src/documents/templates/getResourcesInfo.ts @@ -6,13 +6,14 @@ import { templateKeys } from "../../constants"; import * as TLE from "../../language/expressions/TLE"; import * as Json from "../../language/json/JSON"; +import { isSingleQuoted, removeSingleQuotes } from "../../util/strings"; import { TemplateScope } from "./scopes/TemplateScope"; /** * Get useful info about each resource in the template in a flat list, including the ability to understand full resource names and types in the * presence of parent/child resources */ -export function getResourcesInfo(scope: TemplateScope): IResourceInfo[] { +export function getResourcesInfo(scope: TemplateScope): IJsonResourceInfo[] { const resourcesArray = scope.rootObject?.getPropertyValue(templateKeys.resources)?.asArrayValue; if (resourcesArray) { return getInfoFromResourcesArray(resourcesArray, undefined, scope); @@ -21,28 +22,139 @@ export function getResourcesInfo(scope: TemplateScope): IResourceInfo[] { return []; } +// tslint:disable-next-line: no-suspicious-comment +// TODO: Consider combining with or hanging off of IResource. IResource currently only used for sorting templates and finding nested deployments? Nested subnets are not currently IResources but are IResourceInfos export interface IResourceInfo { - nameExpressions: string[]; - typeExpressions: string[]; + parent?: IResourceInfo; + children: IResourceInfo[]; + + nameSegmentExpressions: string[]; + typeSegmentExpressions: string[]; + + /** + * Provides just the last segment in the name + */ + shortNameExpression: string | undefined; + + /** + * Gets the full name of the resource as a TLE expression + */ + getFullNameExpression(): string | undefined; + + /** + * Gets the type name of the resource as a TLE expression + */ + getFullTypeExpression(): string | undefined; + + /** + * Creates a resourceId expression to reference this resource + */ + getResourceIdExpression(): string | undefined; +} + +export interface IJsonResourceInfo extends IResourceInfo { + /** + * The JSON object that represents this resource + */ + resourceObject: Json.ObjectValue; +} + +export class ResourceInfo implements IResourceInfo { + public readonly children: IResourceInfo[] = []; + public constructor(public readonly nameSegmentExpressions: string[], public readonly typeSegmentExpressions: string[], public readonly parent?: IResourceInfo) { + if (parent) { + parent.children.push(this); + } + } + + public get shortNameExpression(): string { + return this.nameSegmentExpressions[this.nameSegmentExpressions.length - 1]; + } + + public getFullTypeExpression(): string | undefined { + return concatExpressionWithSeparator(this.typeSegmentExpressions, '/'); + } + + public getFullNameExpression(): string | undefined { + return concatExpressionWithSeparator(this.nameSegmentExpressions, '/'); + } + + public getResourceIdExpression(): string | undefined { + if (this.nameSegmentExpressions.length > 0 && this.typeSegmentExpressions.length > 0) { + const typeExpression = this.getFullTypeExpression(); + const nameExpressions = this.nameSegmentExpressions.join(', '); + return `resourceId(${typeExpression}, ${nameExpressions})`; + } - fullTypeName: string; + return undefined; + } } -class ResourceInfo implements IResourceInfo { - public constructor(public nameExpressions: string[], public typeExpressions: string[]) { +export class JsonResourceInfo extends ResourceInfo implements JsonResourceInfo { + public constructor(nameSegmentExpressions: string[], typeSegmentExpressions: string[], public readonly resourceObject: Json.ObjectValue, parent: IJsonResourceInfo | undefined) { + super(nameSegmentExpressions, typeSegmentExpressions, parent); } +} - public get fullTypeName(): string { - if (this.typeExpressions.length > 1) { - return `'${this.typeExpressions.map(removeSingleQuotes).join('/')}'`; +/** + * Concatenates a list of TLE expressions into a single expression, using 'concat' if necessary, and combining string literals when possible. + * @param expressions TLE expressions to concat + * @param unquotedLiteralSeparator An optional separator string literal (without the quotes) to place between every expresion + * + * @example + * concatExpressionsWithSeparator( + * [ `parameters('a')`, `'b'`,`'c'`, `'d'`, `123`, `456`, `parameters('e')`, `parameters('f')`, `'g'`, `'h'` ], + * `/` + * ) + * + * returns + * + * `concat(parameters('a'), '/b/c/d/', 123, '/', 456, '/', parameters('e'), '/', parameters('f'), '/g/h')` + */ +function concatExpressionWithSeparator(expressions: string[], unquotedLiteralSeparator?: string): string | undefined { + if (expressions.length < 2) { + return expressions[0]; + } + + // Coalesce adjacent string literals + const coalescedExpressions: string[] = []; + const expressionsLength = expressions.length; + for (let i = 0; i < expressionsLength; ++i) { + let expression = expressions[i]; + const isLastSegment = i === expressionsLength - 1; + + if (isSingleQuoted(expression)) { + if (unquotedLiteralSeparator && !isLastSegment) { + // Add separator + expression = `'${removeSingleQuotes(expression)}${unquotedLiteralSeparator}'`; + } + + // Merge with last expression if it's also a string literal + const lastCoalescedExpression = coalescedExpressions[coalescedExpressions.length - 1]; + if (lastCoalescedExpression && isSingleQuoted(lastCoalescedExpression)) { + coalescedExpressions[coalescedExpressions.length - 1] = + `'${removeSingleQuotes(lastCoalescedExpression)}${removeSingleQuotes(expression)}'`; + } else { + coalescedExpressions.push(expression); + } } else { - return this.typeExpressions[0]; + coalescedExpressions.push(expression); + if (unquotedLiteralSeparator && !isLastSegment) { + // Add separator + coalescedExpressions.push(`'${unquotedLiteralSeparator}'`); + } } } + + if (coalescedExpressions.length < 2) { + return coalescedExpressions[0]; + } + + return `concat(${coalescedExpressions.join(', ')})`; } -function getInfoFromResourcesArray(resourcesArray: Json.ArrayValue, parent: IResourceInfo | undefined, scope: TemplateScope): IResourceInfo[] { - const results: IResourceInfo[] = []; +function getInfoFromResourcesArray(resourcesArray: Json.ArrayValue, parent: IJsonResourceInfo | undefined, scope: TemplateScope): IJsonResourceInfo[] { + const results: IJsonResourceInfo[] = []; for (let resourceValue of resourcesArray.elements ?? []) { const resourceObject = Json.asObjectValue(resourceValue); if (resourceObject) { @@ -59,7 +171,7 @@ function getInfoFromResourcesArray(resourcesArray: Json.ArrayValue, parent: IRes const typeSegments = splitResourceTypeIntoSegments(resType.unquotedValue); if (parent && typeSegments.length <= 1) { // Add to end of parent type segments - typeExpressions = [...parent.typeExpressions, ...typeSegments]; + typeExpressions = [...parent.typeSegmentExpressions, ...typeSegments]; } else { typeExpressions = typeSegments; } @@ -67,12 +179,12 @@ function getInfoFromResourcesArray(resourcesArray: Json.ArrayValue, parent: IRes const nameSegments = splitResourceNameIntoSegments(resName.unquotedValue, scope); if (parent && nameSegments.length <= 1) { // Add to end of parent name segments - nameExpressions = [...parent.nameExpressions, ...nameSegments]; + nameExpressions = [...parent.nameSegmentExpressions, ...nameSegments]; } else { nameExpressions = nameSegments; } - const info: IResourceInfo = new ResourceInfo(nameExpressions, typeExpressions); + const info: IJsonResourceInfo = new JsonResourceInfo(nameExpressions, typeExpressions, resourceObject, parent); results.push(info); // Check child resources @@ -101,17 +213,22 @@ function getInfoFromResourcesArray(resourcesArray: Json.ArrayValue, parent: IRes // } // } // ] - if (resType.unquotedValue.toLowerCase() === 'Microsoft.Network/virtualNetworks'.toLowerCase()) { + if (resType.unquotedValue.toLowerCase() === 'microsoft.network/virtualnetworks') { const subnets = resourceObject.getPropertyValue(templateKeys.properties)?.asObjectValue ?.getPropertyValue('subnets')?.asArrayValue; for (let subnet of subnets?.elements ?? []) { - const subnetObject = Json.asObjectValue(subnet); + const subnetObject = subnet.asObjectValue; const subnetName = subnetObject?.getPropertyValue(templateKeys.resourceName)?.asStringValue; - if (subnetName) { + if (subnetObject && subnetName) { const subnetTypes = [`'Microsoft.Network/virtualNetworks'`, `'subnets'`]; - const subnetInfo = new ResourceInfo( - [jsonStringToTleExpression(resName.unquotedValue), jsonStringToTleExpression(subnetName.unquotedValue)], - subnetTypes + const subnetInfo = new JsonResourceInfo( + [ + jsonStringToTleExpression(resName.unquotedValue), + jsonStringToTleExpression(subnetName.unquotedValue) + ], + subnetTypes, + subnetObject, + info ); results.push(subnetInfo); } @@ -124,9 +241,9 @@ function getInfoFromResourcesArray(resourcesArray: Json.ArrayValue, parent: IRes return results; } -function isExpression(text: string): boolean { +function isJsonStringAnExpression(text: string): boolean { // Not perfect, but good enough for our purposes here (doesn't handle string starting with "[[", for instance) - return text.length >= 2 && text.startsWith('[') && text.endsWith(']'); + return text.length >= 2 && text[0] === '[' && text[text.length - 1] === ']'; } /** @@ -139,31 +256,24 @@ function isExpression(text: string): boolean { * "[variables('abc')]" => "variables('abc')" */ export function jsonStringToTleExpression(stringUnquotedValue: string): string { - if (isExpression(stringUnquotedValue)) { + if (isJsonStringAnExpression(stringUnquotedValue)) { return stringUnquotedValue.slice(1, stringUnquotedValue.length - 1); } else { return `'${stringUnquotedValue}'`; } } -function removeSingleQuotes(expression: string): string { - if (expression.length >= 2 && expression[0] === `'` && expression.slice(-1) === `'`) { - return expression.slice(1, expression.length - 1); - } - - return expression; -} - -// e.g. +// example1: // "networkSecurityGroup2/networkSecurityGroupRule2" // -> // [ "'networkSecurityGroup2'", "'networkSecurityGroupRule2'" ] // +// example2: // "[concat(variables('sqlServer'), '/' , variables('firewallRuleName'))]" // -> // [ "concat(variables('sqlServer')", "variables('firewallRuleName')" ] export function splitResourceNameIntoSegments(nameUnquotedValue: string, scope: TemplateScope): string[] { - if (isExpression(nameUnquotedValue)) { + if (isJsonStringAnExpression(nameUnquotedValue)) { // It's an expression. Try to break it into segments by handling certain common patterns // No sense taking time for parsing if '/' is nowhere in the expression @@ -255,7 +365,7 @@ function splitStringWithSeparator(s: string, separator: string): string[] { // -> // [ "'Microsoft.Network/networkSecurityGroups'", "'securityRules'" ] function splitResourceTypeIntoSegments(typeNameUnquotedValue: string): string[] { - if (isExpression(typeNameUnquotedValue)) { + if (isJsonStringAnExpression(typeNameUnquotedValue)) { // If it's an expression, we can't know how to split it into segments in a generic // way, so just return the entire expression return [jsonStringToTleExpression(typeNameUnquotedValue)]; diff --git a/src/language/json/JSON.ts b/src/language/json/JSON.ts index 864e2b0a6..b7cc28bc7 100644 --- a/src/language/json/JSON.ts +++ b/src/language/json/JSON.ts @@ -640,6 +640,9 @@ export abstract class Value { return this instanceof Property ? this : undefined; } + /** + * Retrieves the enlosing objects, arrays and properties of the given descendent, in order of top-most to deepest + */ public findLineage(descendent: Value): (Json.ArrayValue | Json.ObjectValue | Json.Property)[] | undefined { return FindLineageVisitor.visit(this, descendent); } diff --git a/src/snippets/InsertionContext.ts b/src/snippets/InsertionContext.ts index 128d0c374..c2254d0bb 100644 --- a/src/snippets/InsertionContext.ts +++ b/src/snippets/InsertionContext.ts @@ -24,7 +24,7 @@ export interface InsertionContext { /** * Is it inside a double-quoted string? */ - insideDoubleQuotes?: boolean; + insideJsonString?: boolean; /** * True if the caller should trigger a completion dropdown diff --git a/src/snippets/SnippetManager.ts b/src/snippets/SnippetManager.ts index de5d2e94c..c86305402 100644 --- a/src/snippets/SnippetManager.ts +++ b/src/snippets/SnippetManager.ts @@ -98,7 +98,7 @@ export class SnippetManager implements ISnippetManager { const name = entry[0]; const detail = `${internalSnippet.description} (${extensionName})`; let label = internalSnippet.prefix; - if (insertionContext.insideDoubleQuotes && !label.startsWith('"')) { + if (insertionContext.insideJsonString && !label.startsWith('"')) { label = `"${label}"`; } diff --git a/src/snippets/showInsertionContext.ts b/src/snippets/showInsertionContext.ts index 679e83ac7..500a9aba0 100644 --- a/src/snippets/showInsertionContext.ts +++ b/src/snippets/showInsertionContext.ts @@ -6,7 +6,7 @@ import { PositionContext } from "../documents/positionContexts/PositionContext"; import { ext } from "../extensionVariables"; export function showInsertionContext(pc: PositionContext): void { - const insertionContext = pc.getInsertionContext(undefined); + const insertionContext = pc.getInsertionContext({ allowInsideJsonString: true }); ext.outputChannel.show(); const context = insertionContext.context ?? '(none)'; ext.outputChannel.appendLine(`Insertion context at ${pc.documentPosition.line + 1},${pc.documentPosition.column + 1}: ${context}`); diff --git a/src/util/debugMarkStrings.ts b/src/util/debugMarkStrings.ts index 05fb7ee30..30d1e868b 100644 --- a/src/util/debugMarkStrings.ts +++ b/src/util/debugMarkStrings.ts @@ -11,8 +11,8 @@ export function __debugMarkPositionInString( text: string, position: number, insertTextAtPosition: string = '', - charactersBeforePosition: number = 45, - charactersAfterPosition: number = 50 + charactersBeforePosition: number = 70, + charactersAfterPosition: number = 70 ): string { if (position >= text.length) { const textAtEnd = `${text.slice(text.length - charactersAfterPosition)}`; diff --git a/src/util/strings.ts b/src/util/strings.ts index 233594bd9..353350781 100644 --- a/src/util/strings.ts +++ b/src/util/strings.ts @@ -122,3 +122,15 @@ export function getCombinedText(values: { toString(): string }[]): string { } return result; } + +export function isSingleQuoted(s: string): boolean { + return s.length >= 2 && s[0] === `'` && s[s.length - 1] === `'`; +} + +export function removeSingleQuotes(expression: string): string { + if (isSingleQuoted(expression)) { + return expression.slice(1, expression.length - 1); + } + + return expression; +} diff --git a/src/vscodeIntegration/Completion.ts b/src/vscodeIntegration/Completion.ts index aa7aa8b21..4530e2657 100644 --- a/src/vscodeIntegration/Completion.ts +++ b/src/vscodeIntegration/Completion.ts @@ -226,6 +226,9 @@ export enum CompletionKind { PropertyValueForExistingProperty = "PropertyValueForExistingProperty", // Parameter from the template file PropertyValueForNewProperty = "PropertyValueForNewProperty", // New, unnamed parameter + // ARM template structure completions + dependsOnResourceId = "dependsOnResourceId", // Completion inside dependsOn of a resourceId reference to a resource + // Snippet Snippet = "Snippet", } diff --git a/src/vscodeIntegration/toVsCodeCompletionItem.ts b/src/vscodeIntegration/toVsCodeCompletionItem.ts index e9fb7d137..8cac1dc6d 100644 --- a/src/vscodeIntegration/toVsCodeCompletionItem.ts +++ b/src/vscodeIntegration/toVsCodeCompletionItem.ts @@ -22,6 +22,7 @@ export function toVsCodeCompletionItem(deploymentFile: DeploymentDocument, item: vscodeItem.commitCharacters = item.commitCharacters; vscodeItem.preselect = item.preselect; vscodeItem.filterText = item.filterText; + vscodeItem.kind = toVsCodeCompletionItemKind(item.kind); let sortPriorityPrefix: string; switch (item.priority) { @@ -41,46 +42,6 @@ export function toVsCodeCompletionItem(deploymentFile: DeploymentDocument, item: // Add priority string to start of sortText, use label if no sortText vscodeItem.sortText = `${sortPriorityPrefix}${item.sortText ?? item.label}`; - switch (item.kind) { - case Completion.CompletionKind.tleFunction: - case Completion.CompletionKind.tleUserFunction: - vscodeItem.kind = vscode.CompletionItemKind.Function; - break; - - case Completion.CompletionKind.tleParameter: - case Completion.CompletionKind.tleVariable: - vscodeItem.kind = vscode.CompletionItemKind.Variable; - break; - - case Completion.CompletionKind.tleProperty: - vscodeItem.kind = vscode.CompletionItemKind.Field; - break; - - case Completion.CompletionKind.tleNamespace: - vscodeItem.kind = vscode.CompletionItemKind.Unit; - break; - - case Completion.CompletionKind.PropertyValueForExistingProperty: - vscodeItem.kind = vscode.CompletionItemKind.Property; - break; - - case Completion.CompletionKind.PropertyValueForNewProperty: - vscodeItem.kind = vscode.CompletionItemKind.Snippet; - break; - - case Completion.CompletionKind.tleResourceIdResTypeParameter: - case Completion.CompletionKind.tleResourceIdResNameParameter: - vscodeItem.kind = vscode.CompletionItemKind.Reference; - break; - - case Completion.CompletionKind.Snippet: - vscodeItem.kind = vscode.CompletionItemKind.Snippet; - break; - - default: - assertNever(item.kind); - } - if (item.additionalEdits) { vscodeItem.additionalTextEdits = item.additionalEdits.map( e => new vscode.TextEdit( @@ -110,6 +71,43 @@ export function toVsCodeCompletionItem(deploymentFile: DeploymentDocument, item: return vscodeItem; } +export function toVsCodeCompletionItemKind(kind: Completion.CompletionKind): vscode.CompletionItemKind { + switch (kind) { + case Completion.CompletionKind.tleFunction: + case Completion.CompletionKind.tleUserFunction: + return vscode.CompletionItemKind.Function; + + case Completion.CompletionKind.tleParameter: + case Completion.CompletionKind.tleVariable: + return vscode.CompletionItemKind.Variable; + + case Completion.CompletionKind.tleProperty: + return vscode.CompletionItemKind.Field; + + case Completion.CompletionKind.tleNamespace: + return vscode.CompletionItemKind.Unit; + + case Completion.CompletionKind.PropertyValueForExistingProperty: + return vscode.CompletionItemKind.Property; + + case Completion.CompletionKind.PropertyValueForNewProperty: + return vscode.CompletionItemKind.Snippet; + + case Completion.CompletionKind.tleResourceIdResTypeParameter: + case Completion.CompletionKind.tleResourceIdResNameParameter: + return vscode.CompletionItemKind.Reference; + + case Completion.CompletionKind.dependsOnResourceId: + return vscode.CompletionItemKind.Reference; + + case Completion.CompletionKind.Snippet: + return vscode.CompletionItemKind.Snippet; + + default: + assertNever(kind); + } +} + /** * This is called after a snippet or other completion item is executed by vscode. Gives us a chance to report it in * telemetry and do any clean-up diff --git a/test/dependsOn.completions.test.ts b/test/dependsOn.completions.test.ts new file mode 100644 index 000000000..e91c17c9e --- /dev/null +++ b/test/dependsOn.completions.test.ts @@ -0,0 +1,959 @@ +// ---------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// ---------------------------------------------------------------------------- + +// tslint:disable:max-func-body-length object-literal-key-quotes + +import * as assert from "assert"; +import { Completion } from '../extension.bundle'; +import { assertEx } from './support/assertEx'; +import { IPartialDeploymentTemplate } from './support/diagnostics'; +import { parseTemplateWithMarkers } from './support/parseTemplate'; + +suite("dependsOn completions", () => { + type PartialCompletionItem = Partial; + + // marks the start of the replace span + // marks the cursor + function createDependsOnCompletionsTest( + testName: string, + options: { + template: IPartialDeploymentTemplate | string; + expected: PartialCompletionItem[]; + triggerCharacter?: string; + } + ): void { + test(testName, async () => { + const { dt, markers: { cursor, replaceStart } } = await parseTemplateWithMarkers(options.template); + assert(cursor, "Missing in testcase template"); + const pc = dt.getContextFromDocumentCharacterIndex(cursor.index, undefined); + const { items: completions } = await pc.getCompletionItems(options.triggerCharacter); + + const actual: PartialCompletionItem[] = completions.map(filterActual); + + assertEx.deepEqual( + actual, + options.expected, + { + ignorePropertiesNotInExpected: true + }); + + function filterActual(item: Completion.Item): PartialCompletionItem { + // tslint:disable-next-line: strict-boolean-expressions + if (!!replaceStart) { + return { + ...item, + replaceSpanStart: replaceStart.index, + replaceSpanText: dt.getDocumentText(item.span) + }; + } else { + return item; + } + } + + }); + } + + suite("completionKinds", () => { + createDependsOnCompletionsTest( + "completionKinds", + { + template: { + resources: [ + { + dependsOn: [ + "" + ] + }, + { + type: "microsoft.abc/def", + name: "name1a" + } + ] + }, + expected: [ + { + "label": "'name1a'", + "kind": Completion.CompletionKind.dependsOnResourceId + } + ] + } + ); + }); + + suite("detail", () => { + createDependsOnCompletionsTest( + "detailMatchesFullTypeName", + { + template: { + resources: [ + { + dependsOn: [ + "" + ] + }, + { + type: "microsoft.abc/def", + name: "name1a" + } + ] + }, + expected: [ + { + "label": "'name1a'", + "insertText": `"[resourceId('microsoft.abc/def', 'name1a')]"`, + "detail": "microsoft.abc/def" + } + ] + }); + }); + + suite("documentation", () => { + createDependsOnCompletionsTest( + "documentation", + { + template: { + resources: [ + { + dependsOn: [ + "" + ] + }, + { + type: "microsoft.abc/def", + name: "name1a" + } + ] + }, + expected: [ + { + // tslint:disable-next-line: no-any + "documention": { + value: + // tslint:disable-next-line: prefer-template + `"[resourceId('microsoft.abc/def', 'name1a')]"\n` + + '\n' + + 'Reference to resource\n' + + "- **Name**: *'name1a'*\n" + + "- **Type**: *'microsoft.abc/def'*" + } + } + ] + } + ); + }); + + suite("label", () => { + createDependsOnCompletionsTest( + "label is the last segment of the name - single segment", + { + template: { + resources: [ + { + dependsOn: [ + "" + ] + }, + { + type: "microsoft.abc/def", + name: "name1a" + } + ] + }, + expected: [ + { + "label": `'name1a'`, + "insertText": `"[resourceId('microsoft.abc/def', 'name1a')]"`, + // tslint:disable-next-line: no-any + "documention": { + value: + // tslint:disable-next-line: prefer-template + `"[resourceId('microsoft.abc/def', 'name1a')]"\n` + + '\n' + + 'Reference to resource\n' + + "- **Name**: *'name1a'*\n" + + "- **Type**: *'microsoft.abc/def'*" + } + } + ] + } + ); + + createDependsOnCompletionsTest( + "label is the last segment of the name - multiple segments", + { + template: { + resources: [ + { + dependsOn: [ + "" + ] + }, + { + type: "microsoft.abc/def/ghi", + name: "name1a/name1b" + } + ] + }, + expected: [ + { + "label": `'name1b'` + } + ] + } + ); + }); + + suite("expressions", () => { + createDependsOnCompletionsTest( + "type is expression", + { + template: { + resources: [ + { + dependsOn: [ + "" + ] + }, + { + type: "[variables('microsoft.abc/def')]", + name: "name1a" + } + ] + }, + expected: [ + { + "label": "'name1a'", + "insertText": `"[resourceId(variables('microsoft.abc/def'), 'name1a')]"` + } + ] + } + ); + + createDependsOnCompletionsTest( + "name is expression that can't be separated", + { + template: { + resources: [ + { + dependsOn: [ + "" + ] + }, + { + type: "microsoft.abc/def", + name: "[concat(parameters('name1a'), 'foo')]" + } + ] + }, + expected: [ + { + "label": "concat(parameters('name1a'), 'foo')", + "insertText": `"[resourceId('microsoft.abc/def', concat(parameters('name1a'), 'foo'))]"` + } + ] + } + ); + }); + + createDependsOnCompletionsTest( + "name is expressions that can be separated 1`", + { + template: { + resources: [ + { + dependsOn: [ + "" + ] + }, + { + type: "microsoft.abc/def", + name: "[concat(parameters('name1a'), '/foo')]" + } + ] + }, + expected: [ + { + "label": "'foo'", + "insertText": `"[resourceId('microsoft.abc/def', parameters('name1a'), 'foo')]"` + } + ] + } + ); + + createDependsOnCompletionsTest( + "name is expressions that can be separated 2`", + { + template: { + resources: [ + { + dependsOn: [ + "" + ] + }, + { + type: "microsoft.abc/def", + name: "[concat(parameters('name1a'), '/', 'foo')]" + } + ] + }, + expected: [ + { + "label": "'foo'", + "insertText": `"[resourceId('microsoft.abc/def', parameters('name1a'), 'foo')]"` + } + ] + } + ); + + createDependsOnCompletionsTest( + "name is expression", + { + template: { + resources: [ + { + dependsOn: [ + "" + ] + }, + { + type: "microsoft.abc/def", + name: "[sum(parameters('name1a'), 1)]" + } + ] + }, + expected: [ + { + "label": "sum(parameters('name1a'), 1)", + "insertText": `"[resourceId('microsoft.abc/def', sum(parameters('name1a'), 1))]"` + } + ] + } + ); + + suite("nested templates", () => { + + createDependsOnCompletionsTest( + "Top level shouldn't find resources in nested scope", + { + template: { + "resources": [ + { + dependsOn: [ + "" + ] + }, + { + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2019-06-01", + "name": "[concat(parameters('projectName'), 'stgdiag')]" + }, + { + "type": "Microsoft.Resources/deployments", + "name": "[concat(parameters('projectName'),'dnsupdate')]", + "dependsOn": [ + "[concat(parameters('projectName'),'lbwebgwpip')]" + ], + "properties": { + "template": { + "resources": [ + { + "name": "[parameters('DNSZone')]", + "type": "Microsoft.Network/dnsZones" + } + ] + } + } + } + ] + }, + "expected": [ + { + "insertText": `"[resourceId('Microsoft.Storage/storageAccounts', concat(parameters('projectName'), 'stgdiag'))]"` + }, + { + "insertText": `"[resourceId('Microsoft.Resources/deployments', concat(parameters('projectName'),'dnsupdate'))]"` + } + ] + }); + + createDependsOnCompletionsTest( + "Inside nested scope shouldn't find top-level resources", + { + template: { + "resources": [ + { + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2019-06-01", + "name": "[concat(parameters('projectName'), 'stgdiag')]" + }, + { + "type": "Microsoft.Resources/deployments", + "name": "[concat(parameters('projectName'),'dnsupdate')]", + "dependsOn": [ + "[concat(parameters('projectName'),'lbwebgwpip')]" + ], + "properties": { + "template": { + "resources": [ + { + "name": "[parameters('DNSZone')]", + "type": "Microsoft.Network/dnsZones" + }, + { + dependsOn: [ + "" + ] + } + ] + } + } + } + ] + }, + "expected": [ + { + "insertText": `"[resourceId('Microsoft.Network/dnsZones', parameters('DNSZone'))]"` + } + ] + }); + }); + + suite("completion triggering", () => { + + createDependsOnCompletionsTest( + "ctrl+space without any quotes", + { + template: `{ + "resources": [ + { + "dependsOn": [ + + ] + }, + { + "type": "microsoft.abc/def", + "name": "name1a" + } + ] + }`, + expected: [ + { + "label": "'name1a'", + "insertText": `"[resourceId('microsoft.abc/def', 'name1a')]"`, + replaceSpanText: `` + } + ] + } + ); + + createDependsOnCompletionsTest( + "ctrl+space just inside existing quotes", + { + template: { + resources: [ + { + dependsOn: [ + "" + ] + }, + { + type: "microsoft.abc/def", + name: "name1a" + } + ] + }, + expected: [ + { + "label": "'name1a'", + "insertText": `"[resourceId('microsoft.abc/def', 'name1a')]"`, + replaceSpanText: `""` + } + ] + } + ); + + createDependsOnCompletionsTest( + "typing double quote to string new string", + { + template: { + resources: [ + { + dependsOn: [ + "" + ] + }, + { + type: "microsoft.abc/def", + name: "name1a" + } + ] + }, + expected: [ + { + "label": "'name1a'", + "insertText": `"[resourceId('microsoft.abc/def', 'name1a')]"`, + replaceSpanText: `""` + } + ], + triggerCharacter: '"' + } + ); + + createDependsOnCompletionsTest( + "replacement includes entire string", + { + template: { + resources: [ + { + dependsOn: [ + "name2a" + ] + }, + { + type: "microsoft.abc/def", + name: "name1a" + } + ] + }, + expected: [ + { + "label": "'name1a'", + "insertText": `"[resourceId('microsoft.abc/def', 'name1a')]"`, + replaceSpanText: `"name2a"` + } + ] + } + ); + + }); + + createDependsOnCompletionsTest( + "case insensitive dependsOn", + { + template: { + resources: [ + { + DEPENDSON: [ + "name2a" + ] + }, + { + type: "microsoft.abc/def", + name: "name1a" + } + ] + }, + expected: [ + { + "label": "'name1a'", + "insertText": `"[resourceId('microsoft.abc/def', 'name1a')]"`, + replaceSpanText: `"name2a"` + } + ] + } + ); + + createDependsOnCompletionsTest( + "multiple resources", + { + template: { + resources: [ + { + dependsOn: [ + "" + ] + }, + { + type: "microsoft.abc/def", + name: "name1a" + }, + { + type: "microsoft.abc/def", + name: "name1b" + }, + { + type: "type2", + name: "name2" + } + ] + }, + expected: [ + { + "label": "'name1a'", + "insertText": `"[resourceId('microsoft.abc/def', 'name1a')]"`, + replaceSpanText: `""` + }, + { + "label": "'name1b'", + "insertText": `"[resourceId('microsoft.abc/def', 'name1b')]"`, + replaceSpanText: `""` + }, + { + "label": "'name2'", + "insertText": `"[resourceId('type2', 'name2')]"`, + replaceSpanText: `""` + } + ] + } + ); + + createDependsOnCompletionsTest( + "No dependsOn completion for the resource to itself", + { + template: { + "resources": [ + { + "name": "[variables('sqlServer')]", + "type": "Microsoft.Sql/servers", + "dependsOn": [ + "'" + ] + } + ] + }, + expected: [ + ] + } + ); + + suite("Child resources", () => { + + suite("Decoupled parent/child", () => { + + createDependsOnCompletionsTest( + "multiple segments in name as string literal", + { + template: { + resources: [ + { + dependsOn: [ + "" + ] + }, + { + type: "microsoft.abc/def/ghi", + name: "name1a/name1b" + } + ] + }, + expected: [ + { + "insertText": `"[resourceId('microsoft.abc/def/ghi', 'name1a', 'name1b')]"` + } + ] + } + ); + + createDependsOnCompletionsTest( + "Reference parent from child", + { + template: { + "resources": [ + { + // Parent + "name": "[variables('sqlServer')]", + "type": "Microsoft.Sql/servers" + }, + { + // Child (decoupled). Requires the parent as part of the name + "name": "[concat(variables('sqlServer'), '/' , variables('firewallRuleName'))]", + "type": "Microsoft.Sql/servers/firewallRules", + "dependsOn": [ + "'" + ] + } + ] + }, + expected: [ + { + label: "variables('sqlServer')", + detail: "Microsoft.Sql/servers", + insertText: `"[resourceId('Microsoft.Sql/servers', variables('sqlServer'))]"` + } + ] + } + ); + + // tslint:disable-next-line: no-suspicious-comment + /* TODO: recognized decoupled children (https://github.com/microsoft/vscode-azurearmtools/issues/492) + createDependsOnCompletionsTest( + "Don't show completion to reference descendents from parent", + { + template: { + "resources": [ + { + // Sibling + "name": "[concat(variables('sqlServer'), '/' , variables('firewallRuleName2'))]", + "type": "Microsoft.Sql/servers/firewallRules", + "resources": [ + { + "name": "siblingchild", + "type": "Microsoft.Sql/servers/firewallRules/whatever" + } + ] + }, + { + // Parent + "name": "[variables('sqlServer')]", + "type": "Microsoft.Sql/servers", + "dependsOn": [ + "'" + ] + }, + { + // Child (decoupled). Requires the parent as part of the name + "name": "[concat(variables('sqlServer'), '/' , variables('firewallRuleName'))]", + "type": "Microsoft.Sql/servers/firewallRules", + "resources": [ + { + "name": "grandchild", + "type": "Microsoft.Sql/servers/firewallRules/whatever" + } + ] + } + ] + }, + expected: [ + { + label: "variables('firewallRuleName2')" + }, + { + label: "siblingchild" + } + ] + } + );*/ + + }); + + suite("nested parent/child", () => { + + createDependsOnCompletionsTest( + "Reference parent from nested child", + { + template: { + "resources": [ + { + // sibling + name: "sibling", + type: "a.b/c" + }, + { + // Parent + "name": "[variables('sqlServer')]", + "type": "Microsoft.Sql/servers", + "resources": [ + { + // Child (nested) - Name must not include parent name + "name": "[variables('firewallRuleName')]", + "type": "firewallRules", + "dependsOn": [ + "" + ] + } + ] + } + ] + }, + expected: [ + { + label: "'sibling'" + }, + { + label: "variables('sqlServer')" + } + ] + } + ); + + createDependsOnCompletionsTest( + "Don't reference nested child or descendents from parent", + { + template: { + "resources": [ + { + // Parent + "name": "[variables('sqlServer')]", + "type": "Microsoft.Sql/servers", + "resources": [ + { + // Child (nested) - Name must not include parent name + "name": "[variables('firewallRuleName')]", + "type": "firewallRules", + "resources": [ + { + "name": "grandchild", + "type": "subrule" + } + ] + } + ], + "dependsOn": [ + "" + ] + }, + { + "name": "sibling", + "type": "abc.def/ghi" + } + ] + }, + expected: [ + { + insertText: `"[resourceId('abc.def/ghi', 'sibling')]"` + } + ] + } + ); + + }); + + suite("nested vnet", () => { + + createDependsOnCompletionsTest( + "Don't reference child subnet from parent vnet", + { + template: { + "resources": [ + { + "type": "Microsoft.Network/virtualNetworks", + "name": "vnet1", + "properties": { + "subnets": [ + // These are considered children, with type Microsoft.Network/virtualNetworks/subnets + { + "name": "subnet1", + "type": "subnets" + } + ] + }, + "dependsOn": [ + "" + ] + } + ] + }, + expected: [ + ] + } + ); + + createDependsOnCompletionsTest( + "Reference parent vnet from child subnet", + { + template: { + "resources": [ + { + "type": "Microsoft.Network/virtualNetworks", + "name": "vnet1", + "properties": { + "subnets": [ + // These are considered children, with type Microsoft.Network/virtualNetworks/subnets + { + "name": "subnet1", + "type": "subnets", + "dependsOn": [ + "" + ] + } + ] + } + } + ] + }, + expected: [ + { + insertText: `"[resourceId('Microsoft.Network/virtualNetworks', 'vnet1')]"` + } + ] + } + ); + + }); + + }); + + suite("Multiple levels of children", () => { + createDependsOnCompletionsTest( + "Reference any resource's children except for direct descendents", + { + template: { + "resources": [ + { + // Parent1 + "name": "[variables('parent1')]", + "type": "Microsoft.Sql/servers", + "resources": [ + { + // Child1a (nested) - should not be in completions + "name": "[variables('child1a')]", + "type": "firewallRules", + "dependsOn": [ + "" + ], + "resources": [ + { + // grandchild1 (nested) - should not be in completions + "name": "grandchild1", + "type": "rulechild" + } + ] + } + ] + }, + { + // Child1b (decoupled) + "name": "parent1/child1b", + "type": "Microsoft.Sql/servers/firewallRules" + }, + { + // Sibling1 + "name": "sibling1", + "type": "Microsoft.Sql/servers", + "resources": [ + { + // Child2a (nested) + "name": "child2a", + "type": "firewallRules", + "resources": [ + { + // grandchild2 (nested) + "name": "grandchild2", + "type": "rulechild" + } + ] + } + ] + }, + { + // Child2b of sibling1 (decoupled) + "name": "[concat('sibling1', '/', variables('child2b'))]", + "type": "Microsoft.Sql/servers/firewallRules" + } + ] + }, + expected: [ + { + label: "variables('parent1')" + }, + { + label: "'child1b'" + }, + { + label: "'sibling1'" + }, + { + label: "'child2a'" + }, + { + label: "'grandchild2'" + }, + { + label: "variables('child2b')" + } + ] + } + ); + }); +}); diff --git a/test/support/assertEx.ts b/test/support/assertEx.ts new file mode 100644 index 000000000..55eb3ee76 --- /dev/null +++ b/test/support/assertEx.ts @@ -0,0 +1,57 @@ +// --------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.md in the project root for license information. +// --------------------------------------------------------------------------------------------- + +import * as assert from "assert"; + +export namespace assertEx { + type keyedObject = { [key: string]: unknown }; + + export interface IEqualExOptions { + /** If true, will only match against the properties in the expected object */ + ignorePropertiesNotInExpected: boolean; + } + + export function deepEqual(actual: T, expected: T, options: IEqualExOptions): asserts actual is T { + let partial: Partial = actual; + if (options.ignorePropertiesNotInExpected) { + partial = >deepPartialClone(actual, expected); + } + + assert.deepStrictEqual(partial, expected); + } + + /** + * Creates a deep clone of 'o' that includes only the (deep) properties of 'shape' + */ + function deepPartialClone(o: unknown, shape: unknown): unknown { + if (Array.isArray(o) || Array.isArray(shape)) { + if (!(Array.isArray(o) || Array.isArray(shape))) { + // Only one is an array + return o; + } + + // tslint:disable-next-line: prefer-array-literal + const a = o; + const arrayShape = shape; + const newArray: unknown[] = []; + + for (let i = 0; i < Math.max(a.length, arrayShape.length); ++i) { + newArray[i] = deepPartialClone(a[i], arrayShape[i]); + } + + return newArray; + } else if (shape instanceof Object && o instanceof Object) { + const clonedObject = {}; + + for (const propName of Object.getOwnPropertyNames(shape)) { + clonedObject[propName] = deepPartialClone((o)[propName], (shape)[propName]); + } + + return clonedObject; + } else { + return o; + } + } +} diff --git a/test/templates/ResourceInfo.test.ts b/test/templates/ResourceInfo.test.ts new file mode 100644 index 000000000..e85de5f48 --- /dev/null +++ b/test/templates/ResourceInfo.test.ts @@ -0,0 +1,386 @@ +// --------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.md in the project root for license information. +// --------------------------------------------------------------------------------------------- + +// tslint:disable: max-func-body-length object-literal-key-quotes + +import * as assert from "assert"; +import { getResourcesInfo, IResourceInfo, ResourceInfo } from "../../extension.bundle"; +import { IPartialDeploymentTemplate } from "../support/diagnostics"; +import { parseTemplate } from "../support/parseTemplate"; + +suite("ResourceInfo", () => { + suite("fullType", () => { + function createFullTypeTest( + typeSegmentExpressions: string[], + expected: string | undefined + ): void { + const testName = JSON.stringify(typeSegmentExpressions); + test(testName, () => { + const ri = new ResourceInfo([], typeSegmentExpressions); + const typeName = ri.getFullTypeExpression(); + assert.deepStrictEqual(typeName, expected); + }); + } + + createFullTypeTest([], undefined); + createFullTypeTest([`'a'`], `'a'`); + createFullTypeTest([`'ms.abc'`], `'ms.abc'`); + createFullTypeTest([`'ms.abc'`, `'def'`], `'ms.abc/def'`); + createFullTypeTest([`'ms.abc'`, `'def'`, `'ghi'`], `'ms.abc/def/ghi'`); + + suite("expressions", () => { + createFullTypeTest([`parameters('abc')`], `parameters('abc')`); + createFullTypeTest([`parameters('abc')`, `'def'`], `concat(parameters('abc'), '/def')`); + createFullTypeTest([`'abc'`, `parameters('def')`], `concat('abc/', parameters('def'))`); + createFullTypeTest([`parameters('abc')`, `parameters('def')`], `concat(parameters('abc'), '/', parameters('def'))`); + createFullTypeTest([`parameters('abc')`, `'def'`, `parameters('ghi')`], `concat(parameters('abc'), '/def/', parameters('ghi'))`); + }); + + suite("coalesce consequence strings", () => { + createFullTypeTest( + [ + `parameters('a')`, `'b'`, `'c'`, `'d'`, `parameters('e')`, `parameters('f')`, `'g'`, `'h'` + ], + `concat(parameters('a'), '/b/c/d/', parameters('e'), '/', parameters('f'), '/g/h')`); + }); + }); + + suite("fullName", () => { + function createFullNameTest( + typeSegmentExpressions: string[], + expected: string | undefined + ): void { + const testName = JSON.stringify(typeSegmentExpressions); + test(testName, () => { + const ri = new ResourceInfo([], typeSegmentExpressions); + const typeName = ri.getFullTypeExpression(); + assert.deepStrictEqual(typeName, expected); + }); + } + + createFullNameTest([], undefined); + createFullNameTest([`'a'`], `'a'`); + createFullNameTest([`'abc'`, `'def'`], `'abc/def'`); + createFullNameTest([`'abc'`, `'def'`, `'ghi'`], `'abc/def/ghi'`); + + suite("expressions", () => { + createFullNameTest([`parameters('abc')`], `parameters('abc')`); + createFullNameTest([`parameters('abc')`, `'def'`], `concat(parameters('abc'), '/def')`); + createFullNameTest([`'abc'`, `parameters('def')`], `concat('abc/', parameters('def'))`); + createFullNameTest([`parameters('abc')`, `parameters('def')`], `concat(parameters('abc'), '/', parameters('def'))`); + createFullNameTest([`parameters('abc')`, `'def'`, `parameters('ghi')`], `concat(parameters('abc'), '/def/', parameters('ghi'))`); + }); + + suite("coalesce consequence strings", () => { + createFullNameTest( + [ + `parameters('a')`, + `'b'`, + `'c'`, + `'d'`, + `123`, + `456`, + `parameters('e')`, + `parameters('f')`, + `'g'`, + `'h'` + ], + `concat(parameters('a'), '/b/c/d/', 123, '/', 456, '/', parameters('e'), '/', parameters('f'), '/g/h')`); + }); + }); + + suite("getResourceIdExpression", () => { + function createResourceIdTest( + testName: string, + resource: IResourceInfo, + expected: string | undefined + ): void { + testName = testName + JSON.stringify(`${resource.getFullTypeExpression()}: ${resource.getFullNameExpression()}`); + test(testName, () => { + const resourceId = resource.getResourceIdExpression(); + assert.deepStrictEqual(resourceId, expected); + }); + } + + suite("empty name or type", () => { + createResourceIdTest("", new ResourceInfo([], []), undefined); + createResourceIdTest("", new ResourceInfo(['a'], []), undefined); + createResourceIdTest("", new ResourceInfo([], ['a']), undefined); + }); + + createResourceIdTest( + "type has expressions", + new ResourceInfo( + [`'a'`], + [`parameters('a')`, `'b'`] + ), + `resourceId(concat(parameters('a'), '/b'), 'a')`); + + createResourceIdTest( + "names with multiple segments (they are not coalesced)", + new ResourceInfo( + [`'a'`, `'b'`, `variables('v1')`, `'c'`], + [`'microsoft.abc/def'`] + ), + `resourceId('microsoft.abc/def', 'a', 'b', variables('v1'), 'c')`); + + }); + + suite("getResourcesInfo", () => { + + test("101-azure-database-migration-service", async () => { + + const template: IPartialDeploymentTemplate = { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "resources": [ + { + "type": "Microsoft.Compute/virtualMachines", + "name": "[variables('sourceServerName')]", + "dependsOn": [ + "[resourceId('Microsoft.Network/networkInterfaces', variables('sourceNicName'))]" + ], + "resources": [ + { + "type": "extensions", + "name": "SqlIaasExtension", + "dependsOn": [ + "[concat('Microsoft.Compute/virtualMachines/', variables('sourceServerName'))]" + ] + }, + { + "type": "extensions", + "name": "CustomScriptExtension", + "dependsOn": [ + "[concat('Microsoft.Compute/virtualMachines/', variables('sourceServerName'))]", + "[concat('Microsoft.Compute/virtualMachines/', concat(variables('sourceServerName'),'/extensions/SqlIaasExtension'))]" + ] + } + ] + }, + { + "type": "Microsoft.DataMigration/services", + "name": "[variables('DMSServiceName')]", + "properties": { + "virtualSubnetId": "[resourceId('Microsoft.Network/virtualNetworks/subnets', variables('adVNet'), variables('defaultSubnetName'))]" + }, + "resources": [ + { + "type": "projects", + "name": "SqlToSqlDbMigrationProject", + "dependsOn": [ + "[resourceId('Microsoft.DataMigration/services', variables('DMSServiceName'))]" + ] + } + ], + "dependsOn": [ + "[resourceId('Microsoft.Network/virtualNetworks/subnets', variables('adVNet'), variables('defaultSubnetName'))]" + ] + }, + { + "type": "Microsoft.Network/networkInterfaces", + "name": "[variables('sourceNicName')]", + "dependsOn": [ + "[resourceId('Microsoft.Network/virtualNetworks/subnets', variables('adVNet'), variables('defaultSubnetName'))]", + "[resourceId('Microsoft.Network/publicIpAddresses', variables('publicIPSourceServer'))]" + ], + "properties": { + "ipConfigurations": [ + { + "name": "ipconfig", + "properties": { + "subnet": { + "id": "[resourceId('Microsoft.Network/virtualNetworks/subnets', variables('adVNet'), variables('defaultSubnetName'))]" + } + } + } + ] + } + }, + { + "type": "Microsoft.Network/networkSecurityGroups", + "name": "[variables('sourceServerNSG')]" + }, + { + "type": "Microsoft.Network/publicIPAddresses", + "name": "[variables('publicIPSourceServer')]" + }, + { + "type": "Microsoft.Network/virtualNetworks", + "name": "[variables('adVNet')]", + "properties": { + "addressSpace": { + "addressPrefixes": [ + "10.2.0.0/24" + ] + }, + "SUBNETS": [ + { + "name": "default", + "properties": { + "addressPrefix": "10.2.0.0/24" + } + } + ], + }, + "resources": [ + { + "type": "Subnets", + "name": "[variables('defaultSubnetName')]", + "dependsOn": [ + "[resourceId('Microsoft.Network/virtualNetworks', variables('adVNet'))]" + ] + } + ] + }, + { + "type": "Microsoft.Storage/storageAccounts", + "name": "[variables('storageAccountName')]" + }, + { + "type": "Microsoft.Sql/servers", + "name": "[concat(variables('targetServerName'))]", + "resources": [ + { + "type": "databases", + "name": "[variables('databaseName')]", + "dependsOn": [ + "[resourceId('Microsoft.Sql/servers', concat(variables('targetServerName')))]" + ], + "resources": [ + { + "name": "Import", + "type": "extensions", + "dependsOn": [ + "[resourceId('Microsoft.Sql/servers/databases', variables('targetServerName'), variables('databaseName'))]" + ] + } + ] + }, + { + "type": "firewallrules", + "name": "AllowAllWindowsAzureIps", + "dependsOn": [ + "[resourceId('Microsoft.Sql/servers', concat(variables('targetServerName')))]" + ] + } + ] + } + ] + }; + + const dt = await parseTemplate(template); + const infos = getResourcesInfo(dt.topLevelScope); + + const actual = infos.map(info => ({ + name: info.shortNameExpression, + type: info.getFullTypeExpression(), + resourceId: info.getResourceIdExpression(), + parent: info.parent?.shortNameExpression + })); + const expected = [ + { + "type": "'Microsoft.Compute/virtualMachines'", + "name": "variables('sourceServerName')", + resourceId: "resourceId('Microsoft.Compute/virtualMachines', variables('sourceServerName'))", + parent: undefined, + }, + { + "type": "'Microsoft.Compute/virtualMachines/extensions'", + "name": "'SqlIaasExtension'", + resourceId: "resourceId('Microsoft.Compute/virtualMachines/extensions', variables('sourceServerName'), 'SqlIaasExtension')", + parent: "variables('sourceServerName')" + }, + { + "type": "'Microsoft.Compute/virtualMachines/extensions'", + "name": "'CustomScriptExtension'", + resourceId: "resourceId('Microsoft.Compute/virtualMachines/extensions', variables('sourceServerName'), 'CustomScriptExtension')", + parent: "variables('sourceServerName')" + }, + { + "type": "'Microsoft.DataMigration/services'", + "name": "variables('DMSServiceName')", + resourceId: "resourceId('Microsoft.DataMigration/services', variables('DMSServiceName'))", + parent: undefined + }, + { + "type": "'Microsoft.DataMigration/services/projects'", + "name": "'SqlToSqlDbMigrationProject'", + resourceId: "resourceId('Microsoft.DataMigration/services/projects', variables('DMSServiceName'), 'SqlToSqlDbMigrationProject')", + parent: "variables('DMSServiceName')" + }, + { + "type": "'Microsoft.Network/networkInterfaces'", + "name": "variables('sourceNicName')", + resourceId: `resourceId('Microsoft.Network/networkInterfaces', variables('sourceNicName'))`, + parent: undefined + }, + { + "type": "'Microsoft.Network/networkSecurityGroups'", + "name": "variables('sourceServerNSG')", + resourceId: "resourceId('Microsoft.Network/networkSecurityGroups', variables('sourceServerNSG'))", + parent: undefined + }, + { + "type": "'Microsoft.Network/publicIPAddresses'", + "name": "variables('publicIPSourceServer')", + resourceId: "resourceId('Microsoft.Network/publicIPAddresses', variables('publicIPSourceServer'))", + parent: undefined + }, + { + "type": "'Microsoft.Network/virtualNetworks'", + "name": "variables('adVNet')", + resourceId: "resourceId('Microsoft.Network/virtualNetworks', variables('adVNet'))", + parent: undefined + }, + { + "type": "'Microsoft.Network/virtualNetworks/Subnets'", + "name": "variables('defaultSubnetName')", + resourceId: `resourceId('Microsoft.Network/virtualNetworks/Subnets', variables('adVNet'), variables('defaultSubnetName'))`, + parent: "variables('adVNet')" + }, + { + "type": "'Microsoft.Network/virtualNetworks/subnets'", + "name": "'default'", + resourceId: "resourceId('Microsoft.Network/virtualNetworks/subnets', variables('adVNet'), 'default')", + parent: "variables('adVNet')" + }, + { + "type": "'Microsoft.Storage/storageAccounts'", + "name": "variables('storageAccountName')", + resourceId: "resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))", + parent: undefined + }, + { + "type": "'Microsoft.Sql/servers'", + "name": "concat(variables('targetServerName'))", + resourceId: "resourceId('Microsoft.Sql/servers', concat(variables('targetServerName')))", + parent: undefined + }, + { + "type": "'Microsoft.Sql/servers/databases'", + "name": "variables('databaseName')", + resourceId: "resourceId('Microsoft.Sql/servers/databases', concat(variables('targetServerName')), variables('databaseName'))", + parent: "concat(variables('targetServerName'))" + }, + { + "type": "'Microsoft.Sql/servers/databases/extensions'", + "name": "'Import'", + resourceId: "resourceId('Microsoft.Sql/servers/databases/extensions', concat(variables('targetServerName')), variables('databaseName'), 'Import')", + parent: "variables('databaseName')" + }, + { + "type": "'Microsoft.Sql/servers/firewallrules'", + "name": "'AllowAllWindowsAzureIps'", + resourceId: "resourceId('Microsoft.Sql/servers/firewallrules', concat(variables('targetServerName')), 'AllowAllWindowsAzureIps')", + parent: "concat(variables('targetServerName'))" + } + ]; + + assert.deepStrictEqual(actual, expected); + }); + + }); +}); From 7640451b14f3f3fed7848adefcb7ad1cdafa584f Mon Sep 17 00:00:00 2001 From: Stephen Weatherford Date: Tue, 1 Sep 2020 17:05:10 -0700 Subject: [PATCH 3/8] fix --- src/documents/positionContexts/TemplatePositionContext.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/documents/positionContexts/TemplatePositionContext.ts b/src/documents/positionContexts/TemplatePositionContext.ts index 28c91227d..257078457 100644 --- a/src/documents/positionContexts/TemplatePositionContext.ts +++ b/src/documents/positionContexts/TemplatePositionContext.ts @@ -286,7 +286,7 @@ export class TemplatePositionContext extends PositionContext { return false; } - public getSnippetInsertionContext(options: { triggerCharacter?: string; allowInsideJsonString?: boolean }): InsertionContext { + public getInsertionContext(options: { triggerCharacter?: string; allowInsideJsonString?: boolean }): InsertionContext { const insertionContext = super.getInsertionContext(options); const context = insertionContext.context; const parents = insertionContext.parents; @@ -408,7 +408,7 @@ export class TemplatePositionContext extends PositionContext { } private getDependsOnCompletionItems(triggerCharacter: string | undefined): Completion.Item[] { - const insertionContext = this.getSnippetInsertionContext({ triggerCharacter, allowInsideJsonString: true }); + const insertionContext = this.getInsertionContext({ triggerCharacter, allowInsideJsonString: true }); if (insertionContext.context === 'dependson') { return getDependsOnCompletions(this); } @@ -417,7 +417,7 @@ export class TemplatePositionContext extends PositionContext { } private async getSnippetCompletionItems(triggerCharacter: string | undefined): Promise { - const insertionContext = this.getSnippetInsertionContext({ triggerCharacter }); + const insertionContext = this.getInsertionContext({ triggerCharacter }); if (insertionContext.triggerSuggest) { return { items: [], triggerSuggest: true }; } else if (insertionContext.context) { From 80ccf8a9420cb47cbdbabd2b2c121c82e381b99a Mon Sep 17 00:00:00 2001 From: Stephen Weatherford Date: Tue, 1 Sep 2020 18:13:43 -0700 Subject: [PATCH 4/8] Improve documentation --- .../templates/getDependsOnCompletions.ts | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/documents/templates/getDependsOnCompletions.ts b/src/documents/templates/getDependsOnCompletions.ts index 685cbfeb0..4440710f6 100644 --- a/src/documents/templates/getDependsOnCompletions.ts +++ b/src/documents/templates/getDependsOnCompletions.ts @@ -2,7 +2,7 @@ import { MarkdownString } from "vscode"; import { assert } from "../../fixed_assert"; import * as Json from "../../language/json/JSON"; import { ContainsBehavior, Span } from "../../language/Span"; -import { removeSingleQuotes } from "../../util/strings"; +import { isSingleQuoted, removeSingleQuotes } from "../../util/strings"; import * as Completion from "../../vscodeIntegration/Completion"; import { TemplatePositionContext } from "../positionContexts/TemplatePositionContext"; import { getResourcesInfo, IJsonResourceInfo, IResourceInfo } from "./getResourcesInfo"; @@ -57,16 +57,26 @@ function getDependsOnCompletion(resource: IResourceInfo, span: Span): Completion if (shortNameExpression && resourceIdExpression) { const label = shortNameExpression; + let typeExpression = resource.getFullTypeExpression(); + if (typeExpression && isSingleQuoted(typeExpression)) { + // Simplify the type expression to remove quotes and the first prefix (e.g. 'Microsoft.Compute/') + typeExpression = removeSingleQuotes(typeExpression); + typeExpression = typeExpression.replace(/^[^/]+\//, ''); + } const insertText = `"[${resourceIdExpression}]"`; - const detail = removeSingleQuotes(resource.getFullTypeExpression() ?? ''); - const resourceResTypeMarkdown = `- **Name**: *${resource.getFullNameExpression()}*\n- **Type**: *${resource.getFullTypeExpression()}*`; - const longDocumentation = `Reference to resource\n${resourceResTypeMarkdown}`; + const detail = typeExpression; + + //const resourceResTypeMarkdown = `- **Name**: *${resource.getFullNameExpression()}*\n- **Type**: *${resource.getFullTypeExpression()}*`; + // const resourceDocumentation = `#### Inserts a resourceId() reference to the following resource:\n${resourceResTypeMarkdown}`; + // const documentation = `\`\`\`csharp\n[${resourceIdExpression}]\n\`\`\`\n${resourceDocumentation}`; + + const documentation = `Inserts this resourceId reference:\n\`\`\`arm-template\n"[${resourceIdExpression}]"\n\`\`\`\n
`; const item = new Completion.Item({ label, insertText: insertText, detail, - documentation: new MarkdownString(`${insertText}\n\n${longDocumentation}`), + documentation: new MarkdownString(documentation), span, kind: Completion.CompletionKind.dependsOnResourceId, // Normally vscode uses label if this isn't specified, but it doesn't seem to like the "[" in the label, From 1b15140822f1bad6495839eb3f1d9089276288c3 Mon Sep 17 00:00:00 2001 From: Stephen Weatherford Date: Tue, 1 Sep 2020 18:26:42 -0700 Subject: [PATCH 5/8] default allow out of bounds=true --- src/documents/positionContexts/TemplatePositionContext.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/documents/positionContexts/TemplatePositionContext.ts b/src/documents/positionContexts/TemplatePositionContext.ts index 257078457..0f7e9db05 100644 --- a/src/documents/positionContexts/TemplatePositionContext.ts +++ b/src/documents/positionContexts/TemplatePositionContext.ts @@ -50,13 +50,13 @@ class TleInfo implements ITleInfo { export class TemplatePositionContext extends PositionContext { private _tleInfo: CachedValue = new CachedValue(); - public static fromDocumentLineAndColumnIndexes(deploymentTemplate: DeploymentTemplateDoc, documentLineIndex: number, documentColumnIndex: number, associatedParameters: DeploymentParametersDoc | undefined, allowOutOfBounds: boolean = false): TemplatePositionContext { + public static fromDocumentLineAndColumnIndexes(deploymentTemplate: DeploymentTemplateDoc, documentLineIndex: number, documentColumnIndex: number, associatedParameters: DeploymentParametersDoc | undefined, allowOutOfBounds: boolean = true): TemplatePositionContext { let context = new TemplatePositionContext(deploymentTemplate, associatedParameters); context.initFromDocumentLineAndColumnIndices(documentLineIndex, documentColumnIndex, allowOutOfBounds); return context; } - public static fromDocumentCharacterIndex(deploymentTemplate: DeploymentTemplateDoc, documentCharacterIndex: number, associatedParameters: DeploymentParametersDoc | undefined, allowOutOfBounds: boolean = false): TemplatePositionContext { + public static fromDocumentCharacterIndex(deploymentTemplate: DeploymentTemplateDoc, documentCharacterIndex: number, associatedParameters: DeploymentParametersDoc | undefined, allowOutOfBounds: boolean = true): TemplatePositionContext { let context = new TemplatePositionContext(deploymentTemplate, associatedParameters); context.initFromDocumentCharacterIndex(documentCharacterIndex, allowOutOfBounds); return context; @@ -314,7 +314,7 @@ export class TemplatePositionContext extends PositionContext { } public async getCompletionItems(triggerCharacter: string | undefined): Promise { - const tleInfo = this.tleInfo; // << BREAKPOINT HERE (TWO) + const tleInfo = this.tleInfo; const completions: Completion.Item[] = []; for (let uniqueScope of this.document.uniqueScopes) { From fd36c9a345a98b89606f7105d58d530a9120acb9 Mon Sep 17 00:00:00 2001 From: Stephen Weatherford Date: Tue, 1 Sep 2020 18:29:54 -0700 Subject: [PATCH 6/8] clean-up --- src/documents/templates/IResource.ts | 4 ++++ src/documents/templates/getDependsOnCompletions.ts | 5 ----- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/documents/templates/IResource.ts b/src/documents/templates/IResource.ts index 79cbba863..e6977f845 100644 --- a/src/documents/templates/IResource.ts +++ b/src/documents/templates/IResource.ts @@ -21,5 +21,9 @@ export interface IResource { * The nameValue of the resource object in the JSON */ nameValue: Json.StringValue | undefined; + + /** + * The typeValue of the resource object in the JSON + */ resourceTypeValue: Json.StringValue | undefined; } diff --git a/src/documents/templates/getDependsOnCompletions.ts b/src/documents/templates/getDependsOnCompletions.ts index 4440710f6..acdf121dd 100644 --- a/src/documents/templates/getDependsOnCompletions.ts +++ b/src/documents/templates/getDependsOnCompletions.ts @@ -65,11 +65,6 @@ function getDependsOnCompletion(resource: IResourceInfo, span: Span): Completion } const insertText = `"[${resourceIdExpression}]"`; const detail = typeExpression; - - //const resourceResTypeMarkdown = `- **Name**: *${resource.getFullNameExpression()}*\n- **Type**: *${resource.getFullTypeExpression()}*`; - // const resourceDocumentation = `#### Inserts a resourceId() reference to the following resource:\n${resourceResTypeMarkdown}`; - // const documentation = `\`\`\`csharp\n[${resourceIdExpression}]\n\`\`\`\n${resourceDocumentation}`; - const documentation = `Inserts this resourceId reference:\n\`\`\`arm-template\n"[${resourceIdExpression}]"\n\`\`\`\n
`; const item = new Completion.Item({ From 5e78fe7c4c885020504d9ce7f34db618153cf13b Mon Sep 17 00:00:00 2001 From: Stephen Weatherford Date: Wed, 2 Sep 2020 09:04:08 -0700 Subject: [PATCH 7/8] suite fixes --- test/TemplatePositionContext.test.ts | 15 ++++++++-- test/dependsOn.completions.test.ts | 44 +++++++++++++++++++--------- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/test/TemplatePositionContext.test.ts b/test/TemplatePositionContext.test.ts index 18e23e1a3..2f3c6c850 100644 --- a/test/TemplatePositionContext.test.ts +++ b/test/TemplatePositionContext.test.ts @@ -83,7 +83,10 @@ suite("TemplatePositionContext", () => { test("with documentLineIndex equal to document line count", () => { let dt = new DeploymentTemplateDoc("{}", fakeId); assert.deepStrictEqual(1, dt.lineCount); - assert.throws(() => { TemplatePositionContext.fromDocumentLineAndColumnIndexes(dt, 1, 0, undefined); }); + let pc = TemplatePositionContext.fromDocumentLineAndColumnIndexes(dt, 1, 0, undefined); + assert.strictEqual(0, pc.documentLineIndex); + assert.strictEqual(0, pc.documentColumnIndex); + assert.strictEqual(0, pc.documentCharacterIndex); }); test("with undefined documentColumnIndex", () => { @@ -105,7 +108,10 @@ suite("TemplatePositionContext", () => { test("with documentColumnIndex greater than line length", () => { let dt = new DeploymentTemplateDoc("{}", fakeId); - assert.throws(() => { TemplatePositionContext.fromDocumentLineAndColumnIndexes(dt, 0, 3, undefined); }); + let pc = TemplatePositionContext.fromDocumentLineAndColumnIndexes(dt, 0, 3, undefined); + assert.strictEqual(0, pc.documentLineIndex); + assert.strictEqual(2, pc.documentColumnIndex); + assert.strictEqual(2, pc.documentCharacterIndex); }); test("with valid arguments", () => { @@ -149,7 +155,10 @@ suite("TemplatePositionContext", () => { test("with documentCharacterIndex greater than the maximum character index", () => { let dt = new DeploymentTemplateDoc("{}", fakeId); - assert.throws(() => { TemplatePositionContext.fromDocumentCharacterIndex(dt, 3, undefined); }); + let pc = TemplatePositionContext.fromDocumentCharacterIndex(dt, 3, undefined); + assert.strictEqual(0, pc.documentLineIndex); + assert.strictEqual(2, pc.documentColumnIndex); + assert.strictEqual(2, pc.documentCharacterIndex); }); test("with valid arguments", () => { diff --git a/test/dependsOn.completions.test.ts b/test/dependsOn.completions.test.ts index e91c17c9e..d5fa42063 100644 --- a/test/dependsOn.completions.test.ts +++ b/test/dependsOn.completions.test.ts @@ -83,7 +83,7 @@ suite("dependsOn completions", () => { suite("detail", () => { createDependsOnCompletionsTest( - "detailMatchesFullTypeName", + "detailMatchesTypeNameMinusSegment", { template: { resources: [ @@ -93,16 +93,34 @@ suite("dependsOn completions", () => { ] }, { - type: "microsoft.abc/def", + type: "Microsoft.abc/def", name: "name1a" + }, + { + type: "microsoft.123/456/789", + name: "name1b" + }, + { + type: "singlesegment", + name: "name1c" } ] }, expected: [ { "label": "'name1a'", - "insertText": `"[resourceId('microsoft.abc/def', 'name1a')]"`, - "detail": "microsoft.abc/def" + "insertText": `"[resourceId('Microsoft.abc/def', 'name1a')]"`, + "detail": "def" + }, + { + "label": "'name1b'", + "insertText": `"[resourceId('microsoft.123/456/789', 'name1b')]"`, + "detail": "456/789" + }, + { + "label": "'name1c'", + "insertText": `"[resourceId('singlesegment', 'name1c')]"`, + "detail": "singlesegment" } ] }); @@ -131,11 +149,10 @@ suite("dependsOn completions", () => { "documention": { value: // tslint:disable-next-line: prefer-template - `"[resourceId('microsoft.abc/def', 'name1a')]"\n` + - '\n' + - 'Reference to resource\n' + - "- **Name**: *'name1a'*\n" + - "- **Type**: *'microsoft.abc/def'*" + "Inserts this resourceId reference:\n" + + "```arm-template\n" + + "\"[resourceId('microsoft.abc/def', 'name1a')]\"\n" + + "```\n
" } } ] @@ -168,11 +185,10 @@ suite("dependsOn completions", () => { "documention": { value: // tslint:disable-next-line: prefer-template + `Inserts this resourceId reference:\n` + + "```arm-template\n" + `"[resourceId('microsoft.abc/def', 'name1a')]"\n` + - '\n' + - 'Reference to resource\n' + - "- **Name**: *'name1a'*\n" + - "- **Type**: *'microsoft.abc/def'*" + "```\n
" } } ] @@ -668,7 +684,7 @@ suite("dependsOn completions", () => { expected: [ { label: "variables('sqlServer')", - detail: "Microsoft.Sql/servers", + detail: "servers", insertText: `"[resourceId('Microsoft.Sql/servers', variables('sqlServer'))]"` } ] From 76a0d9a7d9e9d9d12434d6c7a0d082a8bfd09258 Mon Sep 17 00:00:00 2001 From: Stephen Weatherford Date: Wed, 2 Sep 2020 14:47:18 -0700 Subject: [PATCH 8/8] CR fixes --- .../TemplatePositionContext.ts | 20 ------------------- src/documents/templates/getResourcesInfo.ts | 2 +- src/language/expressions/TLE.ts | 4 ++-- .../toVsCodeCompletionItem.ts | 2 -- 4 files changed, 3 insertions(+), 25 deletions(-) diff --git a/src/documents/positionContexts/TemplatePositionContext.ts b/src/documents/positionContexts/TemplatePositionContext.ts index 0f7e9db05..70d0b0008 100644 --- a/src/documents/positionContexts/TemplatePositionContext.ts +++ b/src/documents/positionContexts/TemplatePositionContext.ts @@ -387,26 +387,6 @@ export class TemplatePositionContext extends PositionContext { return this.document.topLevelScope; } - /** - * Gets the nearest resource object that contains the current position - */ - public getEnclosingResource(): TemplateScope { - if (this.jsonValue && this.document.topLevelValue) { - const objectLineage = <(Json.ObjectValue | Json.ArrayValue)[]>this.document.topLevelValue - ?.findLineage(this.jsonValue) - ?.filter(v => v instanceof Json.ObjectValue); - const scopes = this.document.allScopes; // Note: Use all scopes because resources are unique even when scope is not (see CONSIDER in TemplateScope.ts) - for (const parent of objectLineage.reverse()) { - const innermostMachingScope = scopes.find(s => s.rootObject === parent); - if (innermostMachingScope) { - return innermostMachingScope; - } - } - } - - return this.document.topLevelScope; - } - private getDependsOnCompletionItems(triggerCharacter: string | undefined): Completion.Item[] { const insertionContext = this.getInsertionContext({ triggerCharacter, allowInsideJsonString: true }); if (insertionContext.context === 'dependson') { diff --git a/src/documents/templates/getResourcesInfo.ts b/src/documents/templates/getResourcesInfo.ts index 1874f8255..5aaf4df66 100644 --- a/src/documents/templates/getResourcesInfo.ts +++ b/src/documents/templates/getResourcesInfo.ts @@ -99,7 +99,7 @@ export class JsonResourceInfo extends ResourceInfo implements JsonResourceInfo { /** * Concatenates a list of TLE expressions into a single expression, using 'concat' if necessary, and combining string literals when possible. * @param expressions TLE expressions to concat - * @param unquotedLiteralSeparator An optional separator string literal (without the quotes) to place between every expresion + * @param unquotedLiteralSeparator An optional separator string literal (without the quotes) to place between every expression * * @example * concatExpressionsWithSeparator( diff --git a/src/language/expressions/TLE.ts b/src/language/expressions/TLE.ts index c2646f392..b3b403f92 100644 --- a/src/language/expressions/TLE.ts +++ b/src/language/expressions/TLE.ts @@ -493,7 +493,7 @@ export class FunctionCallValue extends ParentValue { * A TLE value representing a property access (source.property). */ export class PropertyAccess extends ParentValue { - // We need to allow creating a property access expresion whether the property name + // We need to allow creating a property access expression whether the property name // was correctly given or not, so we can have proper intellisense/etc. // I.e., we require the period, but after that might be empty or an error. constructor(private _source: Value, private _periodToken: Token, private _nameToken: Token | undefined) { @@ -861,7 +861,7 @@ export class Parser { errors.push(new Issue(errorSpan!, "Expected a literal value.", IssueKind.tleSyntax)); } - // We go ahead and create a property access expresion whether the property name + // We go ahead and create a property access expression whether the property name // was correctly given or not, so we can have proper intellisense/etc. expression = new PropertyAccess(expression, periodToken, propertyNameToken); } else if (tokenizer.current.getType() === TokenType.LeftSquareBracket) { diff --git a/src/vscodeIntegration/toVsCodeCompletionItem.ts b/src/vscodeIntegration/toVsCodeCompletionItem.ts index 8cac1dc6d..f8edcad6d 100644 --- a/src/vscodeIntegration/toVsCodeCompletionItem.ts +++ b/src/vscodeIntegration/toVsCodeCompletionItem.ts @@ -95,8 +95,6 @@ export function toVsCodeCompletionItemKind(kind: Completion.CompletionKind): vsc case Completion.CompletionKind.tleResourceIdResTypeParameter: case Completion.CompletionKind.tleResourceIdResNameParameter: - return vscode.CompletionItemKind.Reference; - case Completion.CompletionKind.dependsOnResourceId: return vscode.CompletionItemKind.Reference;