import * as _ from 'lodash' ; import { Position , TextDocument } from 'vscode-languageserver-textdocument' ; import { Document , Options , parseCST } from 'yaml' ; import { Node , Pair , Scalar , YAMLMap , YAMLSeq } from 'yaml/types' ; import { IModuleMetadata , IOption } from '../interfaces/module' ; import { DocsLibrary } from '../services/docsLibrary' ; import { isTaskKeyword , playExclusiveKeywords } from './ansible' ; import { playKeywords , taskKeywords } from '../utils/ansible' ; /**
 * A helper class used for building YAML path assertions and retrieving parent
 * nodes. The assertions are built up from the most nested (last in array)
 * element.
 */ export class AncestryBuilder < N extends Node | Pair = Node > { private _path : Node [ ] ; private _index : number ; constructor ( path , index ? ) { this . _path = path || [ ] ; this . _index = index || this . _path . length - 1 ; } /**
     * Move up the path, optionally asserting the type of the parent.
     *
     * Unless Pair is explicitly asserted, it is ignored/skipped over when moving
     * up.
     */ parent < X extends Node | Pair > ( type ? ) { this . _index -- ; if ( this . get ( ) instanceof Pair ) { if ( ! type || ! ( type === Pair . prototype . constructor ) ) { this . _index -- ; } } if ( type ) { if ( ! ( this . get ( ) instanceof type ) ) { this . _index = Number . MIN_SAFE_INTEGER ; } } return this as unknown as AncestryBuilder < X > ; } /**
     * Move up the path, asserting that the current node was a key of a mapping
     * pair. The builder skips over the Pair to the parent YAMLMap.
     */ parentOfKey ( ) { const node = this . get ( ) ; this . parent ( Pair ) ; const pairNode = this . get ( ) ; if ( pairNode instanceof Pair && pairNode . key === node ) { this . parent ( YAMLMap ) ; } else { this . _index = Number . MIN_SAFE_INTEGER ; } return this as unknown as AncestryBuilder < YAMLMap > ; } /**
     * Get node up to which the assertions have led.
     */ get ( ) { return ( this . _path [ this . _index ] as N ) || null ; } /**
     * Get the key of the Pair one level down the path.
     *
     * The key is returned only if it indeed is a string Scalar.
     */ getStringKey ( this ) { const node = this . _path [ this . _index + 1 ] ; if ( node instanceof Pair && node . key instanceof Scalar && typeof node . key . value === 'string' ) { return node . key . value ; } return null ; } /**
     * Get the value of the Pair one level down the path.
     */ getValue ( this ) { const node = this . _path [ this . _index + 1 ] ; if ( node instanceof Pair ) { return node . value ; } return null ; } /**
     * Get the path to which the assertions have led.
     *
     * The path will be a subpath of the original path.
     */ getPath ( ) { if ( this . _index < 0 ) return null ; const path = this . _path . slice ( 0 , this . _index + 1 ) ; return path ; } /**
     * Get the path to the key of the Pair one level down the path to which the
     * assertions have led.
     *
     * The path will be a subpath of the original path.
     */ getKeyPath ( this ) { if ( this . _index < 0 ) return null ; const path = this . _path . slice ( 0 , this . _index + 1 ) ; const node = this . _path [ this . _index + 1 ] ; if ( node instanceof Pair ) { path . push ( node ) ; path . push ( node . key ) ; return path ; } return null ; } } export function getPathAt ( document , position , docs , inclusive = false ) { const offset = document . offsetAt ( position ) ; const doc = _ . find ( docs , ( d ) => contains ( d . contents , offset , inclusive ) ) ; if ( doc && doc . contents ) { return getPathAtOffset ( [ doc . contents ] , offset , inclusive ) ; } return null ; } export function contains ( node , offset , inclusive ) { const range = getOrigRange ( node ) ; return ! ! ( range && range [ 0 ] <= offset && ( range [ 1 ] > offset || ( inclusive && range [ 1 ] >= offset ) ) ) ; } export function getPathAtOffset ( path , offset , inclusive ) { if ( path ) { const currentNode = path [ path . length - 1 ] ; if ( currentNode instanceof YAMLMap ) { let pair = _ . find ( currentNode . items , ( p ) => contains ( p . key , offset , inclusive ) ) ; if ( pair ) { return getPathAtOffset ( path . concat ( pair , pair . key ) , offset , inclusive ) ; } pair = _ . find ( currentNode . items , ( p ) => contains ( p . value , offset , inclusive ) ) ; if ( pair ) { return getPathAtOffset ( path . concat ( pair , pair . value ) , offset , inclusive ) ; } pair = _ . find ( currentNode . items , ( p ) => { const inBetweenNode = new Node ( ) ; const start = getOrigRange ( p . key as Node ) ?. [ 1 ] ; const end = getOrigRange ( p . value as Node ) ?. [ 0 ] ; if ( start && end ) { inBetweenNode . range = [ start , end - 1 ] ; return contains ( inBetweenNode , offset , inclusive ) ; } else return false ; } ) ; if ( pair ) { return path . concat ( pair , new Node ( ) ) ; } } else if ( currentNode instanceof YAMLSeq ) { const item = _ . find ( currentNode . items , ( n ) => contains ( n , offset , inclusive ) ) ; if ( item ) { return getPathAtOffset ( path . concat ( item ) , offset , inclusive ) ; } } else if ( contains ( currentNode , offset , inclusive ) ) { return path ; } return path . concat ( new Node ( ) ) ; } return null ; } export const tasksKey = /^(tasks|pre_tasks|post_tasks|block|rescue|always)$/ ; /**
 * Determines whether the path points at a parameter key of an Ansible task.
 */ export function isTaskParam ( path ) { const taskListPath = new AncestryBuilder ( path ) . parentOfKey ( ) . parent ( YAMLSeq ) . getPath ( ) ; if ( taskListPath ) { if ( isPlayParam ( path ) || isBlockParam ( path ) || isRoleParam ( path ) ) return false ; if ( taskListPath . length === 1 ) { return true ; } const taskListKey = new AncestryBuilder ( taskListPath ) . parent ( YAMLMap ) . getStringKey ( ) ; if ( taskListKey && tasksKey . test ( taskListKey ) ) { return true ; } } return false ; } /**
 * Tries to find the list of collections declared at the Ansible play/block/task level.
 */ export function getDeclaredCollections ( modulePath ) { const declaredCollections = [ ] ; const taskParamsNode = new AncestryBuilder ( modulePath ) . parent ( YAMLMap ) . get ( ) ; declaredCollections . push ( ... getDeclaredCollectionsForMap ( taskParamsNode ) ) ; let path = new AncestryBuilder ( modulePath ) . parent ( YAMLMap ) . getPath ( ) ; while ( true ) { const builder = new AncestryBuilder ( path ) . parent ( YAMLSeq ) . parent ( YAMLMap ) ; const key = builder . getStringKey ( ) ; if ( key && /^block|rescue|always$/ . test ( key ) ) { declaredCollections . push ( ... getDeclaredCollectionsForMap ( builder . get ( ) ) ) ; path = builder . getPath ( ) ; } else { break ; } } const playParamsNode = new AncestryBuilder ( path ) . parent ( YAMLSeq ) . parent ( YAMLMap ) . get ( ) ; declaredCollections . push ( ... getDeclaredCollectionsForMap ( playParamsNode ) ) ; return [ ... new Set ( declaredCollections ) ] ; } function getDeclaredCollectionsForMap ( playNode ) { const declaredCollections = [ ] ; const collectionsPair = _ . find ( playNode ?. items , ( pair ) => pair . key instanceof Scalar && pair . key . value === 'collections' ) ; if ( collectionsPair ) { const collectionsNode = collectionsPair . value ; if ( collectionsNode instanceof YAMLSeq ) { for ( const collectionNode of collectionsNode . items ) { if ( collectionNode instanceof Scalar ) { declaredCollections . push ( collectionNode . value ) ; } } } } return declaredCollections ; } /**
 * Heuristically determines whether the path points at an Ansible play. The
 * `fileUri` helps guessing in case the YAML tree doesn't give any clues.
 *
 * Returns `undefined` if highly uncertain.
 */ export function isPlayParam ( path , fileUri ? ) { const isAtRoot = new AncestryBuilder ( path ) . parentOfKey ( ) . parent ( YAMLSeq ) . getPath ( ) ?. length === 1 ; if ( isAtRoot ) { const mapNode = new AncestryBuilder ( path ) . parentOfKey ( ) . get ( ) as YAMLMap ; const providedKeys = getYamlMapKeys ( mapNode ) ; const containsPlayKeyword = providedKeys . some ( ( p ) => playExclusiveKeywords . has ( p ) ) ; if ( containsPlayKeyword ) { return true ; } if ( fileUri ) { const isInRole = /\/roles\/[^/]+\/tasks\// . test ( fileUri ) ; if ( isInRole ) { return false ; } } } else { return false ; } } /**
 * Determines whether the path points at one of Ansible block parameter keys.
 */ export function isBlockParam ( path ) { const builder = new AncestryBuilder ( path ) . parentOfKey ( ) ; const mapNode = builder . get ( ) ; const isInYAMLSeq = ! ! builder . parent ( YAMLSeq ) . get ( ) ; if ( mapNode && isInYAMLSeq ) { const providedKeys = getYamlMapKeys ( mapNode ) ; return providedKeys . includes ( 'block' ) ; } return false ; } /**
 * Determines whether the path points at one of Ansible role parameter keys.
 */ export function isRoleParam ( path ) { const rolesKey = new AncestryBuilder ( path ) . parentOfKey ( ) . parent ( YAMLSeq ) . parent ( YAMLMap ) . getStringKey ( ) ; return rolesKey === 'roles' ; } /**
 * If the path points at a parameter or sub-parameter provided for a module, it
 * will return the list of all possible options or sub-options at that
 * level/indentation.
 */ export async function getPossibleOptionsForPath ( path , document , docsLibrary ) { const [ taskParamPath , suboptionTrace ] = getTaskParamPathWithTrace ( path ) ; if ( ! taskParamPath ) return null ; const optionTraceElement = suboptionTrace . pop ( ) ; if ( ! optionTraceElement || optionTraceElement [ 1 ] !== 'dict' ) { return null ; } const taskParamNode = taskParamPath [ taskParamPath . length - 1 ] ; if ( ! ( taskParamNode instanceof Scalar ) ) return null ; let module ; if ( taskParamNode . value === 'args' ) { module = await findProvidedModule ( taskParamPath , document , docsLibrary ) ; } else { [ module ] = await docsLibrary . findModule ( taskParamNode . value , taskParamPath , document . uri ) ; } if ( ! module || ! module . documentation ) return null ; let options = module . documentation . options ; suboptionTrace . reverse ( ) ; for ( const [ optionName , optionType ] of suboptionTrace ) { const option = options . get ( optionName ) ; if ( optionName && option ?. type === optionType && option . suboptions ) { options = option . suboptions ; } else { return null ; } } return options ; } /**
 * For a given path, it searches up that path until a path to the task parameter
 * (typically a module name) is found. The trace of keys with indication whether
 * the values hold a 'list' or a 'dict' is preserved along the way and returned
 * alongside.
 */ export function getTaskParamPathWithTrace ( path ) { const trace = [ ] ; while ( ! isTaskParam ( path ) ) { let parentKeyPath = new AncestryBuilder ( path ) . parentOfKey ( ) . parent ( YAMLMap ) . getKeyPath ( ) ; if ( parentKeyPath ) { const parentKeyNode = parentKeyPath [ parentKeyPath . length - 1 ] ; if ( parentKeyNode instanceof Scalar && typeof parentKeyNode . value === 'string' ) { trace . push ( [ parentKeyNode . value , 'dict' ] ) ; path = parentKeyPath ; continue ; } } parentKeyPath = new AncestryBuilder ( path ) . parentOfKey ( ) . parent ( YAMLSeq ) . parent ( YAMLMap ) . getKeyPath ( ) ; if ( parentKeyPath ) { const parentKeyNode = parentKeyPath [ parentKeyPath . length - 1 ] ; if ( parentKeyNode instanceof Scalar && typeof parentKeyNode . value === 'string' ) { trace . push ( [ parentKeyNode . value , 'list' ] ) ; path = parentKeyPath ; continue ; } } return [ [ ] , [ ] ] ; } return [ path , trace ] ; } /**
 * For a given Ansible task parameter path, find the module if it has been
 * provided for the task.
 */ export async function findProvidedModule ( taskParamPath , document , docsLibrary ) { const taskParameterMap = new AncestryBuilder ( taskParamPath ) . parent ( YAMLMap ) . get ( ) ; if ( taskParameterMap ) { const providedParameters = new Set ( getYamlMapKeys ( taskParameterMap ) ) ; const providedModuleNames = [ ... providedParameters ] . filter ( ( x ) => ! x || ! isTaskKeyword ( x ) ) ; for ( const m of providedModuleNames ) { const [ module ] = await docsLibrary . findModule ( m , taskParamPath , document . uri ) ; if ( module ) { return module ; } } } } export function getYamlMapKeys ( mapNode ) { return mapNode . items . map ( ( pair ) => { if ( pair . key && pair . key instanceof Scalar ) { return pair . key . value ; } } ) ; } export function getOrigRange ( node ) { if ( node ?. cstNode ?. range ) { const range = node . cstNode . range ; return [ range . origStart !== undefined ? range . origStart : range . start , range . origEnd !== undefined ? range . origEnd : range . end , ] ; } else { return node ?. range ; } } /** Parsing with the YAML library tailored to the needs of this extension */ export function parseAllDocuments ( str , options ? ) { const cst = parseCST ( str ) ; cst . setOrigRanges ( ) ; const parsedDocuments = [ ] ; for ( const cstDoc of cst ) { const parsedDocument = new Document ( Object . assign ( { keepCstNodes : true } , options ) ) ; parsedDocument . parse ( cstDoc ) ; parsedDocuments . push ( parsedDocument ) ; } return parsedDocuments ; } param textDocument export function isPlaybook ( textDocument ) { if ( textDocument . getText ( ) . trim ( ) . length === 0 ) { return false ; } const yamlDocs = parseAllDocuments ( textDocument . getText ( ) ) ; const path = getPathAt ( textDocument , { line : 1 , character : 1 } , yamlDocs ) ; if ( ! path ) { return false ; } if ( ! ( path [ 0 ] instanceof YAMLSeq ) ) { return false ; } const playbookKeysSet = new Set ( ) ; const playbookJSON = path [ 0 ] . toJSON ( ) ; Object . keys ( playbookJSON ) . forEach ( function ( key ) { Object . keys ( playbookJSON [ key ] ) . forEach ( ( item ) => playbookKeysSet . add ( item ) ) ; } ) ; const playbookKeys = [ ... playbookKeysSet ] ; const playKeywordsList = [ ... playKeywords . keys ( ) ] ; const taskKeywordsList = [ ... taskKeywords . keys ( ) ] ; const filteredList = playKeywordsList . filter ( ( value ) => ! taskKeywordsList . includes ( value ) ) ; const isPlaybookValue = playbookKeys . some ( ( r ) => filteredList . includes ( r ) ) ; return isPlaybookValue ; }