Passed
Push — develop ( a7584e...f458bb )
by Andrew
05:55
created

autocomplete.ts ➔ addCompletionItemsToMonaco   F

Complexity

Conditions 19

Size

Total Lines 106
Code Lines 76

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 76
dl 0
loc 106
rs 0.5999
c 0
b 0
f 0
cc 19

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Complexity

Complex classes like autocomplete.ts ➔ addCompletionItemsToMonaco often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
/**
2
 * Twigfield Craft CMS
3
 *
4
 * Provides a twig editor field with Twig & Craft API autocomplete
5
 *
6
 * @link      https://nystudio107.com
7
 * @copyright Copyright (c) 2022 nystudio107
8
 */
9
10
/**
11
 * @author    nystudio107
12
 * @package   Twigfield
13
 * @since     1.0.0
14
 */
15
16
declare global {
17
  interface Window {
18
    monaco: string;
19
    monacoAutocompleteItems: Array<string>,
20
    twigfieldFieldTypes: Array<string>,
21
  }
22
}
23
24
import {AutocompleteResponse, AutocompleteTypes, COMPLETION_KEY} from './@types/autocomplete'
25
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
26
27
/**
28
 * Get the last item from the array
29
 *
30
 * @param arr
31
 * @returns {*}
32
 */
33
function getLastItem<T>(arr: Array<T>) {
34
  return arr[arr.length - 1];
35
}
36
37
/**
38
 * Register completion items with the Monaco editor, for the Twig language
39
 *
40
 * @param completionItems array of completion items, with sub-properties in `COMPLETION_KEY`
41
 * @param autocompleteType the type of autocomplete, either `TwigExpressionAutocomplete` or `GeneralAutocomplete`
42
 * @param hasSubProperties where the autocomplete has sub-properties, and should be parsed as such
43
 */
44
function addCompletionItemsToMonaco(completionItems: AutocompleteResponse, autocompleteType: AutocompleteTypes, hasSubProperties: boolean) {
45
  monaco.languages.registerCompletionItemProvider('twig', {
46
    triggerCharacters: ['.', '('],
47
    provideCompletionItems: function (model, position, token) {
48
      let result: monaco.languages.CompletionItem[] = [];
49
      let currentItems = completionItems;
50
      // Get the last word the user has typed
51
      const currentLine = model.getValueInRange({
52
        startLineNumber: position.lineNumber,
53
        startColumn: 0,
54
        endLineNumber: position.lineNumber,
55
        endColumn: position.column
56
      });
57
      let inTwigExpression = true;
58
      // Ensure we're inside of a Twig expression
59
      if (currentLine.lastIndexOf('{') === -1) {
60
        inTwigExpression = false;
61
      }
62
      const startExpression = currentLine.substring(currentLine.lastIndexOf('{'));
63
      if (startExpression.indexOf('}') !== -1) {
64
        inTwigExpression = false;
65
      }
66
      // We are not in a Twig expression, and this is a TwigExpressionAutocomplete, return nothing
67
      if (!inTwigExpression && autocompleteType === 'TwigExpressionAutocomplete') {
68
        return null;
69
      }
70
      // Get the current word we're typing
71
      const currentWords = currentLine.replace("\t", "").split(" ");
72
      let currentWord = currentWords[currentWords.length - 1];
73
      // If the current word includes { or ( or >, split on that, too, to allow the autocomplete to work in nested functions and HTML tags
74
      if (currentWord.includes('{')) {
75
        currentWord = getLastItem(currentWord.split('{'));
76
      }
77
      if (currentWord.includes('(')) {
78
        currentWord = getLastItem(currentWord.split('('));
79
      }
80
      if (currentWord.includes('>')) {
81
        currentWord = getLastItem(currentWord.split('>'));
82
      }
83
      const isSubProperty = currentWord.charAt(currentWord.length - 1) === ".";
84
      // If we're in a sub-property (following a .) don't present non-TwigExpressionAutocomplete items
85
      if (isSubProperty && autocompleteType !== 'TwigExpressionAutocomplete') {
86
        return null;
87
      }
88
      // We are in a Twig expression, handle TwigExpressionAutocomplete by walking through the properties
89
      if (inTwigExpression && autocompleteType === 'TwigExpressionAutocomplete') {
90
        // If the last character typed is a period, then we need to look up a sub-property of the completionItems
91
        if (isSubProperty) {
92
          // If we're in a sub-property, and this autocomplete doesn't have sub-properties, don't return its items
93
          if (!hasSubProperties) {
94
            return null;
95
          }
96
          // Is a sub-property, get a list of parent properties
97
          const parents = currentWord.substring(0, currentWord.length - 1).split(".");
98
          if (typeof completionItems[parents[0]] !== 'undefined') {
99
            currentItems = completionItems[parents[0]];
100
            // Loop through all the parents to traverse the completion items and find the current one
101
            for (let i = 1; i < parents.length; i++) {
102
              if (currentItems.hasOwnProperty(parents[i])) {
103
                currentItems = currentItems[parents[i]];
104
              } else {
105
                const finalItems: monaco.languages.ProviderResult<monaco.languages.CompletionList> = {
106
                  suggestions: result
107
                }
108
                return finalItems;
109
              }
110
            }
111
          }
112
        }
113
      }
114
      // Get all the child properties
115
      if (typeof currentItems !== 'undefined') {
116
        for (let item in currentItems) {
117
          if (currentItems.hasOwnProperty(item) && !item.startsWith("__")) {
118
            const completionItem = currentItems[item][COMPLETION_KEY];
119
            if (typeof completionItem !== 'undefined') {
120
              // Monaco adds a 'range' to the object, to denote where the autocomplete is triggered from,
121
              // which needs to be removed each time the autocomplete objects are re-used
122
              delete completionItem.range;
123
              if ('documentation' in completionItem && typeof completionItem.documentation !== 'object') {
124
                let docs = completionItem.documentation;
125
                completionItem.documentation = {
126
                  value: docs,
127
                  isTrusted: true,
128
                  supportsHtml: true
129
                }
130
              }
131
              // Add to final results
132
              result.push(completionItem);
133
            }
134
          }
135
        }
136
      }
137
138
      const finalItems: monaco.languages.ProviderResult<monaco.languages.CompletionList> = {
139
        suggestions: result
140
      }
141
      return finalItems;
142
    }
143
  });
144
}
145
146
/**
147
 * Register hover items with the Monaco editor, for the Twig language
148
 *
149
 * @param completionItems array of completion items, with sub-properties in `COMPLETION_KEY`
150
 * @param autocompleteType the type of autocomplete, either `TwigExpressionAutocomplete` or `GeneralAutocomplete`
151
 * @param hasSubProperties where the autocomplete has sub-properties, and should be parsed as such
152
 */
153
function addHoverHandlerToMonaco(completionItems: AutocompleteResponse, autocompleteType: AutocompleteTypes, hasSubProperties: boolean) {
154
  monaco.languages.registerHoverProvider('twig', {
155
    provideHover: function (model, position) {
156
      let result = {};
157
      const currentLine = model.getValueInRange({
158
        startLineNumber: position.lineNumber,
159
        startColumn: 0,
160
        endLineNumber: position.lineNumber,
161
        endColumn: model.getLineMaxColumn(position.lineNumber)
162
      });
163
      const currentWord = model.getWordAtPosition(position);
164
      if (currentWord === null) {
165
        return;
166
      }
167
      let searchLine = currentLine.substring(0, currentWord.endColumn - 1)
168
      let isSubProperty = false;
169
      let currentItems = completionItems;
170
      for (let i = searchLine.length; i >= 0; i--) {
171
        if (searchLine[i] === ' ') {
172
          searchLine = currentLine.substring(i + 1, searchLine.length);
173
          break;
174
        }
175
      }
176
      if (searchLine.includes('.')) {
177
        isSubProperty = true;
178
      }
179
      if (isSubProperty) {
180
        // Is a sub-property, get a list of parent properties
181
        const parents = searchLine.substring(0, searchLine.length).split(".");
182
        // Loop through all the parents to traverse the completion items and find the current one
183
        for (let i = 0; i < parents.length - 1; i++) {
184
          const thisParent = parents[i].replace(/[{(<]/, '');
185
          if (currentItems.hasOwnProperty(thisParent)) {
186
            currentItems = currentItems[thisParent];
187
          } else {
188
            return result;
189
          }
190
        }
191
      }
192
      if (typeof currentItems !== 'undefined' && typeof currentItems[currentWord.word] !== 'undefined') {
193
        const completionItem = currentItems[currentWord.word][COMPLETION_KEY];
194
        if (typeof completionItem !== 'undefined') {
195
          let docs = completionItem.documentation;
196
          if (typeof completionItem.documentation === 'object') {
197
            docs = completionItem.documentation.value;
198
          }
199
          return {
200
            range: new monaco.Range(position.lineNumber, currentWord.startColumn, position.lineNumber, currentWord.endColum),
201
            contents: [
202
              {value: '**' + completionItem.detail + '**'},
203
              {value: docs},
204
            ]
205
          }
206
        }
207
      }
208
209
      return result;
210
    }
211
  });
212
}
213
214
/**
215
 * Fetch the autocompletion items from local storage, or from the endpoint if they aren't cached in local storage
216
 */
217
function getCompletionItemsFromEndpoint(fieldType = 'Twigfield', twigfieldOptions = '', endpointUrl) {
218
  const searchParams = new URLSearchParams();
219
  if (typeof fieldType !== 'undefined') {
220
    searchParams.set('fieldType', fieldType);
221
  }
222
  if (typeof twigfieldOptions !== 'undefined') {
223
    searchParams.set('twigfieldOptions', twigfieldOptions);
224
  }
225
  const glueChar = endpointUrl.includes('?') ? '&' : '?';
226
  // Only issue the XHR if we haven't loaded the autocompletes for this fieldType already
227
  if (typeof window.twigfieldFieldTypes === 'undefined') {
228
    window.twigfieldFieldTypes = {};
229
  }
230
  if (fieldType in window.twigfieldFieldTypes) {
231
    return;
232
  }
233
  window.twigfieldFieldTypes[fieldType] = fieldType;
234
  // Ping the controller endpoint
235
  let request = new XMLHttpRequest();
236
  request.open('GET', endpointUrl + glueChar + searchParams.toString(), true);
237
  request.onload = function () {
238
    if (request.status >= 200 && request.status < 400) {
239
      const completionItems = JSON.parse(request.responseText);
240
      if (typeof window.monacoAutocompleteItems === 'undefined') {
241
        window.monacoAutocompleteItems = {};
242
      }
243
      // Don't add a completion more than once, as might happen with multiple Twigfield instances
244
      // on the same page, because the completions are global in Monaco
245
      for (const [name, autocomplete] of Object.entries(completionItems)) {
246
        if (!(autocomplete.name in window.monacoAutocompleteItems)) {
247
          window.monacoAutocompleteItems[autocomplete.name] = autocomplete.name;
248
          addCompletionItemsToMonaco(autocomplete.__completions, autocomplete.type, autocomplete.hasSubProperties);
249
          addHoverHandlerToMonaco(autocomplete.__completions, autocomplete.type);
250
        }
251
      }
252
    } else {
253
      console.log('Autocomplete endpoint failed with status ' + request.status)
254
    }
255
  };
256
  request.send();
257
}
258
259
export {getCompletionItemsFromEndpoint};
260