import * as assert from 'assert' ; import * as fse from 'fs-extra' ; import * as path from 'path' ; import { commands , MessageItem , TextDocument , Uri , window , workspace } from 'vscode' ; import { callWithTelemetryAndErrorHandling , DialogResponses , IActionContext , IAzureQuickPickItem , UserCancelledError } from 'vscode-azureextensionui' ; import { armTemplateLanguageId , configKeys , configPrefix , documentSchemes , globalStateKeys } from '../../../common' ; import { ext } from '../../extensionVariables' ; import { normalizeFilePath } from '../../util/normalizedPaths' ; import { pathExistsNoThrow } from '../../util/pathExistsNoThrow' ; import { DeploymentTemplateDoc } from '../templates/DeploymentTemplateDoc' ; import { containsParametersSchema } from '../templates/schemas' ; import { DeploymentFileMapping } from './DeploymentFileMapping' ; import { queryCreateParameterFile } from './parameterFileGeneration' ; import { getRelativeParameterFilePath } from './parameterFilePaths' ; const readAtMostBytesToFindParamsSchema = 4 * 1024 ; const currentMessage = "Current" ; const similarFilenameMessage = "Matching filename" ; const fileNotFoundMessage = "File not found" ; const howToMessage = `Note: You can manually select the parameter file for a template by clicking "Select/Create Parameter File..." in the status bar or the editor context menu.` ; const _filesToIgnoreThisSession = new Set < string > ( ) ; interface IPossibleParameterFile { uri : Uri ; friendlyPath : string ; isCloseNameMatch : boolean ; fileNotFound ? : boolean ; } export async function selectParameterFile ( actionContext , mapping , sourceUri ) { const editor = window . activeTextEditor ; if ( ! sourceUri ) { sourceUri = window . activeTextEditor ?. document . uri ; } if ( ! editor || ! sourceUri || editor . document . uri . fsPath !== sourceUri . fsPath ) { await ext . ui . showWarningMessage ( `Please open an Azure Resource Manager template file before trying to associate or create a parameter file.` ) ; return ; } if ( editor . document . languageId !== armTemplateLanguageId ) { actionContext . telemetry . properties . languageId = editor . document . languageId ; await ext . ui . showWarningMessage ( `The current file "${ sourceUri . fsPath }" does not appear to be an Azure Resource Manager Template. Please open one or make sure the editor Language Mode in the context menu is set to "Azure Resource Manager Template".` ) ; return ; } let templateUri = sourceUri ; if ( templateUri . scheme === documentSchemes . untitled ) { actionContext . errorHandling . suppressReportIssue = true ; throw new Error ( "Please save the template file before associating it with a parameter file." ) ; } const contents = editor . document . getText ( undefined ) ; const template = new DeploymentTemplateDoc ( contents , templateUri , editor . document . version ) ; let quickPickList = await createParameterFileQuickPickList ( mapping , templateUri ) ; const result = await ext . ui . showQuickPick ( quickPickList . items , { canPickMany : false , placeHolder : `Select a parameter file for "${ path . basename ( templateUri . fsPath ) }"` , suppressPersistence : true } ) ; if ( result === quickPickList . none ) { await neverAskAgain ( templateUri , actionContext ) ; await mapping . mapParameterFile ( templateUri , undefined ) ; } else if ( result === quickPickList . browse ) { const paramsPaths = await window . showOpenDialog ( { canSelectMany : false , defaultUri : templateUri , openLabel : "Select Parameter File" } ) ; if ( ! paramsPaths || paramsPaths . length !== 1 ) { throw new UserCancelledError ( ) ; } const selectedParamsPath = paramsPaths [ 0 ] ; if ( ! await isParameterFile ( selectedParamsPath . fsPath ) ) { const selectAnywayResult = await ext . ui . showWarningMessage ( `"${ selectedParamsPath . fsPath }" does not appear to be a valid parameter file. Select it anyway?` , { modal : true } , DialogResponses . yes , DialogResponses . no ) ; if ( selectAnywayResult !== DialogResponses . yes ) { throw new UserCancelledError ( ) ; } } await neverAskAgain ( templateUri , actionContext ) ; await mapping . mapParameterFile ( templateUri , selectedParamsPath ) ; } else if ( result === quickPickList . newFile ) { let newUri = await queryCreateParameterFile ( actionContext , template . topLevelScope ) ; await mapping . mapParameterFile ( templateUri , newUri ) ; await commands . executeCommand ( 'azurerm-vscode-tools.openParameterFile' , templateUri , newUri ) ; } else if ( result === quickPickList . openCurrent ) { await commands . executeCommand ( 'azurerm-vscode-tools.openParameterFile' , templateUri ) ; } else if ( result . data === quickPickList . currentParamFile ) { dontAskAgainThisSession ( templateUri , actionContext ) ; } else { assert ( result . data , "Quick pick item should have had data" ) ; await neverAskAgain ( templateUri , actionContext ) ; await mapping . mapParameterFile ( templateUri , result . data ?. uri ) ; } } export async function openParameterFile ( mapping , templateUri , parameterUri ) { if ( templateUri ) { let paramFile = parameterUri || mapping . getParameterFile ( templateUri ) ; if ( ! paramFile ) { throw new Error ( `There is currently no parameter file for template file "${ templateUri . fsPath }"` ) ; } let doc = await workspace . openTextDocument ( paramFile ) ; await window . showTextDocument ( doc ) ; } } export async function openTemplateFile ( mapping , parameterUri , templateUri ) { if ( parameterUri ) { templateUri = templateUri ?? mapping . getTemplateFile ( parameterUri ) ; if ( ! templateUri ) { throw new Error ( `There is no template file currently associated with parameter file "${ parameterUri . fsPath }"` ) ; } if ( await pathExistsNoThrow ( templateUri ) ) { let doc = await workspace . openTextDocument ( templateUri ) ; await window . showTextDocument ( doc ) ; } else { const remove = { title : `Unlink` } ; const response = await ext . ui . showWarningMessage ( `Could not find associated template file "${ templateUri . fsPath }".  Unlink this association?` , remove , DialogResponses . cancel ) ; if ( response . title === remove . title ) { await mapping . mapParameterFile ( templateUri , undefined ) ; } } } } /**
 * If the file is inside the workspace folder, use the path relative to that, otherwise
 * use the absolute path.  This is intended for UI only.
 */ export function getFriendlyPathToFile ( uri ) { const workspaceFolder = workspace . getWorkspaceFolder ( uri ) ; if ( workspaceFolder ) { return path . relative ( workspaceFolder . uri . fsPath , uri . fsPath ) ; } else { return uri . fsPath ; } } interface IQuickPickList { items : IAzureQuickPickItem < IPossibleParameterFile | undefined > [ ] ; currentParamFile : IPossibleParameterFile | undefined ; none : IAzureQuickPickItem < IPossibleParameterFile | undefined > ; newFile : IAzureQuickPickItem < IPossibleParameterFile | undefined > ; browse : IAzureQuickPickItem < IPossibleParameterFile | undefined > ; openCurrent : IAzureQuickPickItem < IPossibleParameterFile | undefined > ; } async function createParameterFileQuickPickList ( mapping , templateUri ) { let suggestions = await findSuggestedParameterFiles ( templateUri ) ; const currentParamUri = mapping . getParameterFile ( templateUri ) ; const currentParamPathNormalized = currentParamUri ? normalizeFilePath ( currentParamUri ) : undefined ; let currentParamFile = suggestions . find ( pf => normalizeFilePath ( pf . uri ) === currentParamPathNormalized ) ; if ( currentParamUri && ! currentParamFile ) { currentParamFile = { isCloseNameMatch : false , uri : currentParamUri , friendlyPath : getRelativeParameterFilePath ( templateUri , currentParamUri ) } ; let exists = await pathExistsNoThrow ( currentParamUri ) ; currentParamFile . fileNotFound = ! exists ; suggestions = suggestions . concat ( currentParamFile ) ; } let pickItems = suggestions . map ( paramFile => createQuickPickItem ( paramFile , currentParamFile , templateUri ) ) ; sortQuickPickList ( pickItems ) ; const currentItem = pickItems . find ( pi => pi . data === currentParamFile ) ; if ( currentItem ) { pickItems = [ currentItem ] . concat ( pickItems . filter ( ppf => ppf !== currentItem ) ) ; } const none = { label : "$(circle-slash) None" , description : ! ! currentParamUri ? undefined : currentMessage , data : undefined } ; const browse = { label : '$(file-directory) Browse...' , data : undefined } ; const openCurrent = { label : '$(go-to-file) Open Current' , data : undefined } ; const newFile = { label : '$(new-file) New...' , data : undefined } ; pickItems = [ none ] . concat ( pickItems ) . concat ( [ newFile , browse ] ) ; if ( currentItem ) { pickItems = pickItems . concat ( [ openCurrent ] ) ; } return { items : pickItems , currentParamFile , none , browse , openCurrent , newFile } ; } function sortQuickPickList ( pickItems ) { pickItems . sort ( ( a , b ) => { const aData = a ?. data ; const bData = a ?. data ; if ( a ?. data ?. isCloseNameMatch !== b ?. data ?. isCloseNameMatch ) { return a ?. data ?. isCloseNameMatch ? - 1 : 1 ; } return ( aData ?. uri . fsPath || "" ) . localeCompare ( bData ?. uri . fsPath || "" ) ; } ) ; } function createQuickPickItem ( paramFile , current , templateUri ) { const isCurrent = paramFile === current ; let description = isCurrent ? currentMessage : paramFile . isCloseNameMatch ? similarFilenameMessage : undefined ; if ( paramFile . fileNotFound ) { description += ` $(error) ${ fileNotFoundMessage }` ; } return { label : `${ isCurrent ? '$(check) ' : '$(json) ' } ${ paramFile . friendlyPath }` , data : paramFile , description } ; } /**
 * Finds parameter files to suggest for a given template.
 */ export async function findSuggestedParameterFiles ( templateUri ) { let paths = [ ] ; return await callWithTelemetryAndErrorHandling ( 'findSuggestedParameterFiles' , async ( actionContext ) => { actionContext . errorHandling . rethrow = false ; try { const folder = path . dirname ( templateUri . fsPath ) ; const fileNames = await fse . readdir ( folder ) ; for ( let paramFileName of fileNames ) { const fullPath = path . join ( folder , paramFileName ) ; const uri = Uri . file ( fullPath ) ; if ( await isParameterFile ( fullPath ) && templateUri . fsPath !== fullPath ) { paths . push ( { uri , friendlyPath : getRelativeParameterFilePath ( templateUri , uri ) , isCloseNameMatch : mayBeMatchingParameterFile ( templateUri . fsPath , fullPath ) } ) ; } } } catch ( error ) { } return paths ; } ) || [ ] ; } async function isParameterFile ( filePath ) { try { if ( ! hasSupportedParameterFileExtension ( filePath ) ) { return false ; } if ( await doesFileContainString ( filePath , containsParametersSchema , readAtMostBytesToFindParamsSchema ) ) { return true ; } } catch ( error ) { } return false ; } async function doesFileContainString ( filePath , matches , maxBytesToRead ) { return new Promise < boolean > ( ( resolve , reject ) : void => { try { const stream = fse . createReadStream ( filePath , { encoding : 'utf8' } ) ; let content = '' ; stream . on ( 'data' , ( chunk ) => { content += chunk ; if ( matches ( content ) ) { stream . close ( ) ; resolve ( true ) ; } if ( content . length >= maxBytesToRead ) { stream . close ( ) ; resolve ( false ) ; } } ) ; stream . on ( 'end' , ( ) => { resolve ( false ) ; } ) ; stream . on ( 'error' , ( err ) => { reject ( err ) ; } ) ; } catch ( err ) { reject ( err ) ; } } ) ; } /**
 * Determines if a file is likely a parameter file for the given template file, based on name.
 * Common patterns are:
 *   template.json, template.params.json
 *   template.json, template.parameters.json
 */ export function mayBeMatchingParameterFile ( templateFileName , parameterFileName ) { if ( ! hasSupportedParameterFileExtension ( parameterFileName ) ) { return false ; } const baseTemplateName = removeAllExtensions ( path . basename ( templateFileName ) ) . toLowerCase ( ) ; const baseParamsName = removeAllExtensions ( path . basename ( parameterFileName ) ) . toLowerCase ( ) ; return baseParamsName . startsWith ( baseTemplateName ) ; } function removeAllExtensions ( fileName ) { return fileName . replace ( /\..+$/ , '' ) ; } /**
 * Search for potential parameter file matches for the given document, and ask the user if appropriate whether to associate it
 */ export function considerQueryingForParameterFileInBackground ( mapping , document ) { callWithTelemetryAndErrorHandling ( 'queryAddParameterFile' , async ( actionContext ) : Promise < void > => { const templateUri = document . uri ; if ( templateUri . scheme !== documentSchemes . file ) { return ; } const alreadyHasParamFile = ! ! mapping . getParameterFile ( document . uri ) ; actionContext . telemetry . properties . alreadyHasParamFile = String ( alreadyHasParamFile ) ; if ( alreadyHasParamFile ) { return ; } if ( ! canAsk ( templateUri , actionContext ) ) { return ; } const suggestions = await findSuggestedParameterFiles ( document . uri ) ; const closeMatches = suggestions . filter ( pf => pf . isCloseNameMatch ) ; actionContext . telemetry . measurements . closeMatches = closeMatches . length ; actionContext . telemetry . measurements . totalFound = suggestions . length ; const closestMatch = closeMatches . length > 0 ? closeMatches . sort ( pf => - pf . uri . fsPath . length ) [ 0 ] : undefined ; if ( ! closestMatch ) { return ; } if ( closeMatches . length === 1 ) { actionContext . telemetry . properties . automaticallyPickedClosestMatch = 'true' ; ext . outputChannel . appendLine ( `Found and mapped a matching parameter file: "${ document . uri }" -> "${ closestMatch . uri . fsPath }"` ) ; await mapping . mapParameterFile ( templateUri , closestMatch . uri ) ; return ; } const select = { title : "Select..." } ; const ignore = { title : "None" } ; const response = await window . showInformationMessage ( `Multiple matching parameter files have been detected for template file "${ getFriendlyPathToFile ( templateUri ) }". Please select one to validate against.` , select , ignore ) ; if ( ! response ) { actionContext . telemetry . properties . response = 'Canceled' ; throw new UserCancelledError ( ) ; } actionContext . telemetry . properties . response = response . title ; switch ( response . title ) { case ignore . title : await neverAskAgain ( templateUri , actionContext ) ; window . showInformationMessage ( howToMessage ) ; break ; case select . title : await commands . executeCommand ( "azurerm-vscode-tools.selectParameterFile" , templateUri ) ; break ; default : assert ( "considerQueryingForParameterFile: Unexpected response" ) ; break ; } } ) . catch ( err => { assert . fail ( "callWithTelemetryAndErrorHandling in considerQueryingForParameterFile shouldn't throw" ) ; } ) ; } function canAsk ( templateUri , actionContext ) { const checkForMatchingParamFilesSetting = ! ! workspace . getConfiguration ( configPrefix ) . get < boolean > ( configKeys . checkForMatchingParameterFiles ) ; actionContext . telemetry . properties . checkForMatchingParamFiles = String ( checkForMatchingParamFilesSetting ) ; if ( ! checkForMatchingParamFilesSetting ) { return false ; } const neverAskFiles = ext . context . globalState . get < string [ ] > ( globalStateKeys . dontAskAboutParameterFiles ) ?? [ ] ; const key = normalizeFilePath ( templateUri ) ; if ( neverAskFiles . includes ( key ) ) { actionContext . telemetry . properties . isInDontAskList = 'true' ; return false ; } let ignoreThisSession = _filesToIgnoreThisSession . has ( normalizeFilePath ( templateUri ) ) ; if ( ignoreThisSession ) { actionContext . telemetry . properties . ignoreThisSession = 'true' ; return false ; } return true ; } function dontAskAgainThisSession ( templateUri , actionContext ) { _filesToIgnoreThisSession . add ( normalizeFilePath ( templateUri ) ) ; } async function neverAskAgain ( templateUri , actionContext ) { const neverAskFiles = ext . context . globalState . get < string [ ] > ( globalStateKeys . dontAskAboutParameterFiles ) || [ ] ; const key = normalizeFilePath ( templateUri ) ; neverAskFiles . push ( key ) ; await ext . context . globalState . update ( globalStateKeys . dontAskAboutParameterFiles , neverAskFiles ) ; } function hasSupportedParameterFileExtension ( filePath ) { const extension = path . extname ( filePath ) . toLowerCase ( ) ; return extension === '.json' || extension === '.jsonc' ; }