twig-autocomplete.ts ➔ addCompletionItemsToMonaco   F
last analyzed

Complexity

Conditions 19

Size

Total Lines 106
Code Lines 76

Duplication

Lines 0
Ratio 0 %

Importance

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