Analyzing Code Context in Monaco Editor for Custom Suggestions
Nitish Kumar Singh
Nov 17, 2024Hi Everyone! In this post, we are going to understand and write code to find the cursor position and analyze the code being written by the user in the Monaco Web Code Editor to provide suggestions accordingly.
Monaco
The Monaco Code Editor is a powerful, feature-rich code editor developed by Microsoft and is best known as the editor that powers Visual Studio Code. Monaco offers advanced features like syntax highlighting, autocompletion, code folding, and error highlighting. It automatically shows suggestions for the current file’s code and gives us the ability to control and show custom suggestions.
When we want to show custom suggestions, we need to understand the context of the code currently being written by the user by analyzing the code around the cursor.
Here, we primarily focus on analyzing JSX code and providing suggestions for it. We will write a JavaScript function that is called every time we need to understand the context of the code to provide suggestions.
Implementation of Monaco
I am using monaco-editor/react
for my React project to make a browser-based code editor. You can find its full implementation guide on this library's NPM package page. Install it in your React project by running the command below:
npm install @monaco-editor/react
I am using it in a React class component and storing Monaco editor and library references in class properties (editor and monaco) using the onMount listener.
The code for my React component is as below:
export default class CodeEditor extends Component {
editor; monaco;
render() {
return (
<div className="bg-gray-100">
<Editor onChange={this.onCodeChange} onMount={this.onMount} language="javascript" />
</div>
);
}
onCodeChange = (data,e)=>{
if (e.changes && e.changes.length === 1 && e.changes[0].rangeLength === 0 && e.changes[0].text !== "") {
let w = this.inJsx();
if (w && (w.typingTag || w.attr)) {
if (w.attr === "style") {
if (!w.rule) {
if (w.mw && w.value.endsWith(w.mw)) {
w.byMe = true; this.w = w;
this.editor.trigger('keyboard', 'editor.action.triggerSuggest', {});
return;
}
}else if(this.suggestionBox.canSuggestValueForRule(w.rule,w.mw)){
w.byMe = true; this.w = w;
this.editor.trigger('keyboard', 'editor.action.triggerSuggest', {});
return;
}
} else if (w.attr === "className") {
w.byMe = true; this.w = w;
console.log(w);
this.editor.trigger('keyboard', 'editor.action.triggerSuggest', {});
return;
}else if(w.value){
if(this.suggestionBox.canSuggestValueForAttr(w.attr,w.mw)) {
w.byMe = true; this.w = w;
this.editor.trigger('keyboard', 'editor.action.triggerSuggest', {});
return;
}
}else {
w.byMe = true; this.w = w;
this.editor.trigger('keyboard', 'editor.action.triggerSuggest', {});
return;
}
}
}
if(this.w) this.w.byMe = false;
}
inJsx = ()=>{
// Our main code that we are going to write in this post
}
onMount = (e,m)=> {
this.editor = e; this.monaco = m;
this.editor.addCommand(m.KeyMod.CtrlCmd | m.KeyCode.KeyS,this.ctrlS);
this.monaco.languages.registerCompletionItemProvider('javascript', {
provideCompletionItems: (model, position,context) => {
let w = this.w; this.w = undefined; let suggestions;
// Here if write code to create suggestions based on information available in variable w returned by our function of context detection
return { suggestions };
}
});
}
}
Monaco automatically calls the provideCompletionItems
function registered by registerCompletionItemProvider
when the user needs suggestions according to Monaco. However, it does not get called when writing within string literals.
Therefore, in the above code, I check the context of the code being written on every change because I want to provide suggestions for all JSX elements like tags, attributes, styles, and class names (e.g., pre-built Tailwind CSS class names). This excludes cases like backspace, delete, code changes by suggestion selection, and pasting code.
Below is the main function that performs code analysis and provides related information.
inJsx = ()=>{
const position = this.editor.getPosition();
const model = this.editor.getModel();
let r = new this.monaco.Range(Math.max(1,position.lineNumber-2),1,position.lineNumber,position.column);
let code = model.getValueInRange(r), re = "",j=-1,encloser="",attr="",value="",word="",mw="";
let firstAttrFound = false,gotFirstAttrValuePair = false;
let firstAttr = "",enclosers = "";
for(let i = code.length - 1; i >= 0; i--){
let char = code[i], c = char.charCodeAt(0);
if(char === ">") break;
if (!gotFirstAttrValuePair) {
if((char === '"' || char === "'" || char === "{") && code[i-1] === "="){
if (!enclosers.endsWith(char==="{"?"}":char)) {
encloser = char;
value = code.substring(i+1);
let m = code.substring(0,i-1);
attr = value ? m.substring(m.lastIndexOf(" ")+1) : "";
word = value ? value.substring(value.lastIndexOf(" ")+1) : "";
let mwt = model.getWordAtPosition(position);
mw = value && mwt ? mwt.word : "";
}else if(firstAttr) attr = firstAttr;
gotFirstAttrValuePair = true;
}else if ((char === '"' || char === "'" || char === "{" || char === "}")) {
if (char !== "}" && enclosers.endsWith(char==="{"?"}":char)) {
enclosers = enclosers.substring(0,enclosers.lastIndexOf(char==="{"?"}":char));
}else enclosers = enclosers + char;
}
}
if(!firstAttr && !firstAttrFound && char === " ") firstAttr = code.substring(i+1);
if (!firstAttrFound && !(c >= 65 && c <= 90) && !(c >= 97 && c <= 122) && !(c >= 48 && c <= 57)) firstAttrFound = true;
if(char === "<"){
re = code.substring(i+1); j = re.indexOf(" ");
re = j === -1 ? re : re.substring(0,j);
if(/^\/|[0-9]/.test(re)) re = "";
break;
}
}
if(!attr && firstAttr) attr = firstAttr;
let n = re ? {tag:re,typingTag:j===-1,encloser,attr,value,word,mw,byMe:false}:undefined;
if (n && n.attr === "style" && n.mw) {
const match = n.value.match(`(\\w+)\\s*:\\s*[^,]*${n.mw}$`);
if(match) n.rule = match[1];
}
return n;
}
Functions Used in the Monaco Editor
In the above code, the following Monaco editor functions are used:
- getPosition: Returns an object containing the lineNumber and column of the current cursor position in the code.
- getModel: Returns a Monaco Model object containing many useful methods. It essentially holds information about the currently open and edited code file.
- Range: Creates a range between two cursor positions in the code. It is used with the getValueInRange function to retrieve code within this range.
- getValueInRange: Returns the code within the provided range.
- getWordAtPosition: Returns the word currently being typed, from the cursor position backward up to a word separator like a space or dash.
Code Analysis in JSX
In the inJsx function, we first retrieve the last three lines of code from the cursor position, assuming that a JSX tag can span up to three lines. We also define some variables.
Afterward, we start looking backward from the cursor position, character by character, to extract the information we need based on conditions specific to JSX syntax.
Handling Closing Tags
The first condition checks for the closing tag character (>). If found, it means we are outside a tag, so the loop stops. We then check for the attribute and its value until the first one is encountered.
Extracting Tag Names
When writing tag names, no condition under the loop will be true except for the one that checks for the starting tag character (<).
if(char === "<"){
re = code.substring(i+1); j = re.indexOf(" ");
re = j === -1 ? re : re.substring(0,j);
if(/^\/|[0-9]/.test(re)) re = "";
break;
}
For example, if we are writing a tag name like <di
, the extracted code looks like ......... <di
. Here, re="di"
, j
will be -1
, and in other cases when writing after a tag, j
will not be -1
. The code below extracts this information, ensuring that the tag name does not start with a number.
let n = re ? {tag:re,typingTag:j===-1,encloser,attr,value,word,mw,byMe:false}:undefined;
Extracting Attribute Names
When writing an attribute name, the first condition that will be true
is shown below. In the second condition, we ensure that no invalid character is encountered in the attribute name. If an invalid character is found, the attribute name is not extracted here but is handled by a different condition.
if(!firstAttr && !firstAttrFound && char === " ") firstAttr = code.substring(i+1);
if (!firstAttrFound && !(c >= 65 && c <= 90) && !(c >= 97 && c <= 122) && !(c >= 48 && c <= 57)) firstAttrFound = true;
If this code fails to extract the attribute name (which happens when writing the value of an attribute), it extracts the attribute name along with its value and finally stores it in attr
outside the loop.
Extracting Attributes and Their Values
When writing an attribute's value, we check if the current character is an enclosure character for the attribute's value and if the character just before it is an equal sign (=).
This indicates that we have encountered an attribute, so we extract it along with its value, provided the enclosure does not already have a corresponding closing character, as shown below:
if((char === '"' || char === "'" || char === "{") && code[i-1] === "="){
if (!enclosers.endsWith(char==="{"?"}":char)) {
encloser = char;
value = code.substring(i+1);
let m = code.substring(0,i-1);
attr = value ? m.substring(m.lastIndexOf(" ")+1) : "";
word = value ? value.substring(value.lastIndexOf(" ")+1) : "";
let mwt = model.getWordAtPosition(position);
mw = value && mwt ? mwt.word : "";
}else if(firstAttr) attr = firstAttr;
gotFirstAttrValuePair = true;
}
However, if we do not encounter an equal sign just before the enclosure, it indicates the closing of an attribute value or string literal. In this case, we update the enclosers
variable, adding or removing characters based on whether they are opening or closing characters.
else if ((char === '"' || char === "'" || char === "{" || char === "}")) {
if (char !== "}" && enclosers.endsWith(char==="{"?"}":char)) {
enclosers = enclosers.substring(0,enclosers.lastIndexOf(char==="{"?"}":char));
}else enclosers = enclosers + char;
}
Special Case: Style Attributes
If the attribute being written is style, we extract information to provide suggestions for writing inline styles based on JSX syntax, as shown below:
if (n && n.attr === "style" && n.mw) {
const match = n.value.match(`(\\w+)\\s*:\\s*[^,]*${n.mw}$`);
if(match) n.rule = match[1];
}
Conclusion
This is the complete code for recognizing where and what the user is writing in JSX and showing suggestions accordingly.
I know the above code is not fully optimized, and there are some pitfalls. For example, we assume no spaces around the equal sign in attribute-value declarations and do not handle cases where the equal sign or enclosure characters appear within an attribute's value. However, the code still works well, and I had a similar experience to writing JSX in VS Code while testing my project.
I hope you enjoyed reading this post, learned something, and found it informative. If you have any questions, suggestions, or topics to discuss, feel free to connect with me. Until then, Happy Coding!