[IMP] owl-vision: Autocomplete and added missing owl directives

This commit adds basic autocomplete in  xml files. This includes autocompletion
for elements, components, props, attributes, and javascript expressions.

It also:
- Adds "Go To Definition" support for props and javascript expressions in xml
- Support for the following directives: t-att, t-model, t-tag, t-debug, t-log
- Fixes t-else syntax highlight to be non-dynamic as the attribute value
should be empty
This commit is contained in:
Bastien Fafchamps (bafa)
2024-03-27 16:52:37 +01:00
committed by Géry Debongnie
parent 2eb151c92d
commit 55c48b2b12
12 changed files with 6640 additions and 5570 deletions
+22
View File
@@ -4,6 +4,28 @@ All notable changes to the "owl-vision" extension will be documented in this fil
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [0.1.0] - 2024-11-06
### Added
- Basic autocomplete in xml files. This includes autocompletion for elements, components,
props, attributes, and javascript expressions.
The current implementation, while relatively simple, has a couple of drawbacks:
- Javascript imports are not resolved by the xml autocomplete, this means that it does not
understand the types of imported functions or objects. That said, I've added custom support
for frequently used Owl imports, namely `useState` and `useRef`. You can add more in
the settings if needed.
- The autocomplete is limited to templates directly linked to components, sub-templates
used via t-call will not get autocompletion as no component/context can be bound to them.
- "Go To Definition" support for props and javascript expressions in xml
- Support for the following directives: t-att, t-model, t-tag, t-debug, t-log
### Fixed
- Changed t-else syntax highlight from dynamic to static attribute
## [0.0.2] - 2023-2-11
### Added
+59 -29
View File
@@ -1,16 +1,20 @@
{
"name": "owl-vision",
"version": "0.0.1",
"version": "0.0.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "owl-vision",
"version": "0.0.1",
"version": "0.0.2",
"license": "LGPL-3.0-only",
"dependencies": {
"xmldoc": "^1.3.0"
},
"devDependencies": {
"@types/node": "20.2.5",
"@types/vscode": "^1.73.0",
"@types/xmldoc": "^1.1.9",
"@typescript-eslint/eslint-plugin": "^5.59.8",
"@typescript-eslint/parser": "^5.59.8",
"@vscode/test-electron": "^2.3.2",
@@ -541,6 +545,12 @@
"integrity": "sha512-BHu51NaNKOtDf3BOonY3sKFFmZKEpRkzqkZVpSYxowLbs5JqjOQemYFob7Gs5rpxE5tiGhfpnMpcdF/oKrLg4w==",
"dev": true
},
"node_modules/@types/xmldoc": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@types/xmldoc/-/xmldoc-1.1.9.tgz",
"integrity": "sha512-HLwIAudQ9xedPOK9rKd7gSHYzM5qtWOzae9z5tM7dRDR1hWeNlFSejfnxFMIv06mm2LmtX+pzVQ4GN86vf/b3g==",
"dev": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
@@ -801,6 +811,19 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@vscode/vsce/node_modules/xml2js": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
"dev": true,
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/acorn": {
"version": "8.10.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz",
@@ -2834,8 +2857,7 @@
"node_modules/sax": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.3.0.tgz",
"integrity": "sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==",
"dev": true
"integrity": "sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA=="
},
"node_modules/semver": {
"version": "7.5.4",
@@ -3214,19 +3236,6 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true
},
"node_modules/xml2js": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
"dev": true,
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
@@ -3236,6 +3245,14 @@
"node": ">=4.0"
}
},
"node_modules/xmldoc": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/xmldoc/-/xmldoc-1.3.0.tgz",
"integrity": "sha512-y7IRWW6PvEnYQZNZFMRLNJw+p3pezM4nKYPfr15g4OOW9i8VpeydycFuipE2297OvZnh3jSb2pxOt9QpkZUVng==",
"dependencies": {
"sax": "^1.2.4"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
@@ -3552,6 +3569,12 @@
"integrity": "sha512-BHu51NaNKOtDf3BOonY3sKFFmZKEpRkzqkZVpSYxowLbs5JqjOQemYFob7Gs5rpxE5tiGhfpnMpcdF/oKrLg4w==",
"dev": true
},
"@types/xmldoc": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@types/xmldoc/-/xmldoc-1.1.9.tgz",
"integrity": "sha512-HLwIAudQ9xedPOK9rKd7gSHYzM5qtWOzae9z5tM7dRDR1hWeNlFSejfnxFMIv06mm2LmtX+pzVQ4GN86vf/b3g==",
"dev": true
},
"@typescript-eslint/eslint-plugin": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
@@ -3705,6 +3728,16 @@
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"xml2js": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
"dev": true,
"requires": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
}
}
}
},
@@ -5233,8 +5266,7 @@
"sax": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.3.0.tgz",
"integrity": "sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==",
"dev": true
"integrity": "sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA=="
},
"semver": {
"version": "7.5.4",
@@ -5513,22 +5545,20 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true
},
"xml2js": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
"dev": true,
"requires": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
}
},
"xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"dev": true
},
"xmldoc": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/xmldoc/-/xmldoc-1.3.0.tgz",
"integrity": "sha512-y7IRWW6PvEnYQZNZFMRLNJw+p3pezM4nKYPfr15g4OOW9i8VpeydycFuipE2297OvZnh3jSb2pxOt9QpkZUVng==",
"requires": {
"sax": "^1.2.4"
}
},
"yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+12 -1
View File
@@ -4,7 +4,7 @@
"description": "Owl framework extension that highlights templates and ease navigation between components and templates.",
"publisher": "Odoo",
"license": "LGPL-3.0-only",
"version": "0.0.2",
"version": "0.1.0",
"repository": {
"type": "git",
"url": "https://github.com/odoo/owl/tree/master/tools/owl-vision"
@@ -63,6 +63,13 @@
"type": "string",
"default": "**/node_modules/**,**/lib/**,**/tests/**",
"description": "Files to exclude in search"
},
"owl-vision.autocomplete-mocks": {
"order": 2,
"type": "string",
"editPresentation": "multilineText",
"default": "/**\n* @template T\n* @param {T} obj\n* @returns {T}\n*/\nfunction useState(obj) {}\n\n/**\n* @typedef {Object} Ref\n* @property {HTMLElement} el\n*/\n/**\n* @returns {Ref}\n*/\nfunction useRef(name) {}",
"description": "Mocks for functions or object that are imported but not resolved by the autcomplete. Add docstring comments for them to work properly."
}
}
},
@@ -128,6 +135,7 @@
"devDependencies": {
"@types/node": "20.2.5",
"@types/vscode": "^1.73.0",
"@types/xmldoc": "^1.1.9",
"@typescript-eslint/eslint-plugin": "^5.59.8",
"@typescript-eslint/parser": "^5.59.8",
"@vscode/test-electron": "^2.3.2",
@@ -135,5 +143,8 @@
"esbuild": "^0.19.5",
"eslint": "^8.41.0",
"typescript": "^5.1.3"
},
"dependencies": {
"xmldoc": "^1.3.0"
}
}
@@ -62,7 +62,6 @@ export const propsAttributes = createAttributePatterns("props-attributes", {
export const owlAttributesDynamic = createAttributePatterns("owl-attributes-dynamic", {
match: [
"t-if",
"t-else",
"t-elif",
"t-foreach",
"t-as",
@@ -75,6 +74,10 @@ export const owlAttributesDynamic = createAttributePatterns("owl-attributes-dyna
"t-value",
"t-portal",
"t-slot-scope",
"t-att",
"t-tag",
"t-log",
"t-model",
"t-att-[a-z_:.-]+",
"t-on-[a-z_:.-]+"
].join("|"),
@@ -86,12 +89,13 @@ export const owlAttributesDynamic = createAttributePatterns("owl-attributes-dyna
export const owlAttributesStatic = createAttributePatterns("owl-attributes-static", {
match: [
"t-name",
"t-else",
"t-ref",
"t-set-slot",
"t-model",
"t-inherit",
"t-inherit-mode",
"t-translation"
"t-translation",
"t-debug",
].join("|"),
attributeName: "owl.attribute owl.attribute.static",
});
@@ -1,29 +0,0 @@
import * as vscode from 'vscode';
import { getSelectedText, showStatusMessage, hideStatusMessage } from './utils';
import { Search } from './search';
export class ComponentDefinitionProvider implements vscode.DefinitionProvider {
search: Search;
constructor(search: Search) {
this.search = search;
}
/**
* Interface implementation to provide definition when ctrl+click on Component
* tag in template.
*/
async provideDefinition(document: vscode.TextDocument, position: vscode.Position) {
const currentWord = getSelectedText(/<\/?[A-Z][a-zA-Z]+/, document, position);
if (!currentWord) {
return;
}
const componentName = currentWord.replace(/[\/<]/g, "").trim();
showStatusMessage(`Searching for component "${componentName}"`);
const result = await this.search.findComponent(componentName);
hideStatusMessage();
return result;
}
}
+4 -5
View File
@@ -1,20 +1,19 @@
import * as vscode from 'vscode';
import { Search } from './search';
import { ComponentDefinitionProvider } from './definiton_providers';
import { OpenDirection } from './utils';
import { OwlLanguageFeaturesProvider } from './language_features/language_features_provider';
export async function activate(context: vscode.ExtensionContext) {
const search = new Search();
context.subscriptions.push(vscode.commands.registerCommand('owl-vision.switch', () => search.switch()));
context.subscriptions.push(vscode.commands.registerCommand('owl-vision.switch-besides', () => search.switch(OpenDirection.Besides)));
context.subscriptions.push(vscode.commands.registerCommand('owl-vision.switch-below', () => search.switch(OpenDirection.Below)));
context.subscriptions.push(vscode.commands.registerCommand('owl-vision.find-component', () => search.findComponentCommand()));
context.subscriptions.push(vscode.commands.registerCommand('owl-vision.find-template', () => search.findTemplateCommand()));
const componentDefProvider = new ComponentDefinitionProvider(search);
context.subscriptions.push(vscode.languages.registerDefinitionProvider({ language: 'xml' }, componentDefProvider));
context.subscriptions.push(vscode.languages.registerDefinitionProvider({ language: 'javascript' }, componentDefProvider));
const languageFeaturesProvider = new OwlLanguageFeaturesProvider(search);
context.subscriptions.push(vscode.languages.registerCompletionItemProvider({ language: 'xml', scheme: 'file' }, languageFeaturesProvider, '.', '<'));
context.subscriptions.push(vscode.languages.registerDefinitionProvider({ language: 'xml', scheme: 'file' }, languageFeaturesProvider));
}
export function deactivate() { }
@@ -0,0 +1,395 @@
import { CompletionItemKind } from "vscode"
export const owlComponentAttributes = [
"t-if",
"t-else",
"t-elif",
"t-foreach",
"t-as",
"t-key",
"t-esc",
"t-out",
"t-props",
"t-set",
"t-value",
"t-portal",
"t-slot-scope",
"t-log",
].map(label => ({
label: label,
insertText: label + '=""',
kind: CompletionItemKind.Property,
}));
export const owlElementAttributes = [
"t-component",
"t-att",
"t-tag",
"t-model",
].map(label => ({
label: label,
insertText: label + '=""',
kind: CompletionItemKind.Property,
}));
owlElementAttributes.push(...owlComponentAttributes);
/**
To generate this list, run the following snippet on https://developer.mozilla.org/fr/docs/Web/Events
(function () {
const events = [...document.querySelectorAll(".section-content li a")]
.map(e => e.childNodes[0])
.filter(n => n.nodeType === 3 && n.textContent.toLowerCase() == n.textContent)
.map(n => "t-on-" + n.textContent.trim());
return JSON.stringify([...new Set(events)], null, 2);
}())
*/
export const events = [
"t-on-abort",
"t-on-ended",
"t-on-addtrack",
"t-on-change",
"t-on-removetrack",
"t-on-messageerror",
"t-on-message",
"t-on-animationcancel",
"t-on-animationend",
"t-on-animationiteration",
"t-on-animationstart",
"t-on-copy",
"t-on-cut",
"t-on-dragend",
"t-on-dragenter",
"t-on-dragleave",
"t-on-dragover",
"t-on-dragstart",
"t-on-drag",
"t-on-drop",
"t-on-fullscreenchange",
"t-on-fullscreenerror",
"t-on-gotpointercapture",
"t-on-keydown",
"t-on-keypress",
"t-on-keyup",
"t-on-lostpointercapture",
"t-on-paste",
"t-on-pointercancel",
"t-on-pointerdown",
"t-on-pointerenter",
"t-on-pointerleave",
"t-on-pointerlockchange",
"t-on-pointerlockerror",
"t-on-pointermove",
"t-on-pointerout",
"t-on-pointerover",
"t-on-pointerup",
"t-on-readystatechange",
"t-on-scroll",
"t-on-selectionchange",
"t-on-selectstart",
"t-on-touchcancel",
"t-on-touchend",
"t-on-touchmove",
"t-on-touchstart",
"t-on-transitioncancel",
"t-on-transitionend",
"t-on-transitionrun",
"t-on-transitionstart",
"t-on-visibilitychange",
"t-on-wheel",
"t-on-afterscriptexecute",
"t-on-auxclick",
"t-on-beforescriptexecute",
"t-on-blur",
"t-on-click",
"t-on-compositionend",
"t-on-compositionstart",
"t-on-compositionupdate",
"t-on-contextmenu",
"t-on-dblclick",
"t-on-error",
"t-on-focusin",
"t-on-focusout",
"t-on-focus",
"t-on-gesturechange",
"t-on-gestureend",
"t-on-gesturestart",
"t-on-mousedown",
"t-on-mouseenter",
"t-on-mouseleave",
"t-on-mousemove",
"t-on-mouseout",
"t-on-mouseover",
"t-on-mouseup",
"t-on-mousewheel",
"t-on-overflow",
"t-on-select",
"t-on-show",
"t-on-underflow",
"t-on-webkitmouseforcechanged",
"t-on-webkitmouseforcedown",
"t-on-webkitmouseforceup",
"t-on-webkitmouseforcewillbegin",
"t-on-open",
"t-on-loadend",
"t-on-loadstart",
"t-on-load",
"t-on-progress",
"t-on-webglcontextcreationerror",
"t-on-webglcontextlost",
"t-on-webglcontextrestored",
"t-on-toggle",
"t-on-cancel",
"t-on-close",
"t-on-beforeinput",
"t-on-input",
"t-on-formdata",
"t-on-reset",
"t-on-submit",
"t-on-invalid",
"t-on-search",
"t-on-canplaythrough",
"t-on-canplay",
"t-on-durationchange",
"t-on-emptied",
"t-on-loadeddata",
"t-on-loadedmetadata",
"t-on-pause",
"t-on-playing",
"t-on-play",
"t-on-ratechange",
"t-on-seeked",
"t-on-seeking",
"t-on-stalled",
"t-on-suspend",
"t-on-timeupdate",
"t-on-volumechange",
"t-on-waiting",
"t-on-slotchange",
"t-on-cuechange",
"t-on-enterpictureinpicture",
"t-on-leavepictureinpicture",
"t-on-versionchange",
"t-on-blocked",
"t-on-upgradeneeded",
"t-on-success",
"t-on-complete",
"t-on-devicechange",
"t-on-mute",
"t-on-unmute",
"t-on-merchantvalidation",
"t-on-paymentmethodchange",
"t-on-shippingaddresschange",
"t-on-shippingoptionchange",
"t-on-payerdetailchange",
"t-on-resourcetimingbufferfull",
"t-on-resize",
"t-on-bufferedamountlow",
"t-on-closing",
"t-on-tonechange",
"t-on-gatheringstatechange",
"t-on-selectedcandidatepairchange",
"t-on-statechange",
"t-on-addstream",
"t-on-connectionstatechange",
"t-on-datachannel",
"t-on-icecandidateerror",
"t-on-icecandidate",
"t-on-iceconnectionstatechange",
"t-on-icegatheringstatechange",
"t-on-negotiationneeded",
"t-on-removestream",
"t-on-signalingstatechange",
"t-on-track",
"t-on-audioprocess",
"t-on-activate",
"t-on-contentdelete",
"t-on-install",
"t-on-notificationclick",
"t-on-pushsubscriptionchange",
"t-on-push",
"t-on-connect",
"t-on-audioend",
"t-on-audiostart",
"t-on-end",
"t-on-nomatch",
"t-on-result",
"t-on-soundend",
"t-on-soundstart",
"t-on-speechend",
"t-on-speechstart",
"t-on-start",
"t-on-voiceschanged",
"t-on-boundary",
"t-on-mark",
"t-on-resume",
"t-on-unload",
"t-on-afterprint",
"t-on-appinstalled",
"t-on-beforeprint",
"t-on-beforeunload",
"t-on-devicemotion",
"t-on-deviceorientation",
"t-on-gamepadconnected",
"t-on-gamepaddisconnected",
"t-on-hashchange",
"t-on-languagechange",
"t-on-offline",
"t-on-online",
"t-on-orientationchange",
"t-on-pagehide",
"t-on-pageshow",
"t-on-popstate",
"t-on-rejectionhandled",
"t-on-storage",
"t-on-unhandledrejection",
"t-on-vrdisplayactivate",
"t-on-vrdisplayblur",
"t-on-vrdisplayconnect",
"t-on-vrdisplaydeactivate",
"t-on-vrdisplaydisconnect",
"t-on-vrdisplayfocus",
"t-on-vrdisplaypointerrestricted",
"t-on-vrdisplaypointerunrestricted",
"t-on-vrdisplaypresentchange",
"t-on-timeout",
"t-on-inputsourceschange",
"t-on-selectend",
"t-on-squeezeend",
"t-on-squeezestart",
"t-on-squeeze"
].map(label => ({
label: label,
insertText: label + '=""',
kind: CompletionItemKind.Property,
}))
/**
To generate the elements list, run the following snippet on https://developer.mozilla.org/en-US/docs/Web/HTML/Element
(function () {
const names = [...document.querySelectorAll("section:not([aria-labelledby='obsolete_and_deprecated_elements']) td:nth-child(1)")]
.flatMap(n => n.innerText.split(","))
.map(n => n.trim().replace("<", "").replace(">", ""))
return JSON.stringify([...new Set(names)], null, 2);
})()
*/
export const elements = [
"t",
"link",
"meta",
"style",
"title",
"body",
"address",
"article",
"aside",
"footer",
"header",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hgroup",
"main",
"nav",
"section",
"search",
"blockquote",
"dd",
"div",
"dl",
"dt",
"figcaption",
"figure",
"hr",
"li",
"menu",
"ol",
"p",
"pre",
"ul",
"a",
"abbr",
"b",
"bdi",
"bdo",
"br",
"cite",
"code",
"data",
"dfn",
"em",
"i",
"kbd",
"mark",
"q",
"rp",
"rt",
"ruby",
"s",
"samp",
"small",
"span",
"strong",
"sub",
"sup",
"time",
"u",
"var",
"wbr",
"area",
"audio",
"img",
"map",
"track",
"video",
"embed",
"iframe",
"object",
"picture",
"portal",
"source",
"svg",
"math",
"canvas",
"noscript",
"script",
"del",
"ins",
"caption",
"col",
"colgroup",
"table",
"tbody",
"td",
"tfoot",
"th",
"thead",
"tr",
"button",
"datalist",
"fieldset",
"form",
"input",
"label",
"legend",
"meter",
"optgroup",
"option",
"output",
"progress",
"select",
"textarea",
"details",
"dialog",
"summary",
].map(label => ({
label: label,
insertText: label,
kind: CompletionItemKind.Property,
}));
@@ -0,0 +1,422 @@
import { CancellationToken, CompletionContext, CompletionItem, CompletionItemKind, CompletionItemProvider, CompletionList, DefinitionProvider, Location, Position, Range, TextDocument, TextDocumentContentProvider, Uri, commands, workspace } from "vscode";
import { Search } from "../search";
import { getSelectedText, hash, readFile } from "../utils";
import { elements, events, owlComponentAttributes, owlElementAttributes } from "./items";
import { ParseResultType, ParseResult, parse, getNodePath, parseXml } from "./parser";
/**
* Commands return basic js object which needs to be converted
* to actual CompletionItem instances, this methods streamlines
* this process.
*/
function mapCompletionItems(items: any): CompletionItem[] {
return items.map((i: any) => {
const item = new CompletionItem(i.label, i.kind);
item.sortText = i.sortText;
item.detail = i.detail;
item.filterText = i.filterText;
item.insertText = i.insertText?.startsWith?.(".") ? i.insertText.substring(1) : i.insertText;
return item;
});
}
function filterComponentItems(items: CompletionItem[], excludedLabels: string[] = []): CompletionItem[] {
return items.filter((item) => {
return !excludedLabels.includes(item.label.toString()) && [
CompletionItemKind.Field,
CompletionItemKind.Method,
CompletionItemKind.Variable,
CompletionItemKind.Property,
].includes(item.kind as number);
});
}
/**
* Adds "this." in front of the expression if needed and increments
* the expression offset accordingly.
*/
function contextualize(properties: string[], expression: string, expressionOffset = 0) {
const match = expression.match(/^([a-zA-Z_]+)\b/);
if (!expression.startsWith("this.") && ((match && properties.includes(match[1])) || expression.match(/^\s*$/))) {
expression = "this." + expression;
expressionOffset += 5;
}
return { expression, expressionOffset };
};
const Commands = {
Completion: "vscode.executeCompletionItemProvider",
Definition: "vscode.executeDefinitionProvider",
}
export class OwlLanguageFeaturesProvider implements CompletionItemProvider, TextDocumentContentProvider, DefinitionProvider {
virtualDocuments = new Map();
componentProperties = new Map();
search: Search;
constructor(search: Search) {
this.search = search;
workspace.registerTextDocumentContentProvider("owl", this);
}
/**
* TextDocumentContentProvider interface implementation to provide
* virtual documents to vscode commands.
*/
async provideTextDocumentContent(uri: Uri) {
const id = uri.toString(true);
return this.virtualDocuments.get(id);
}
/**
* DefinitionProvider interface implementation.
*
* - If the target is a js expression, will try to find the definition
* inside the current component.
* - If the target is a component element, will try to find the definition
* of the component.
*/
async provideDefinition(document: TextDocument, position: Position) {
let offset = document.offsetAt(position);
const documentText = document.getText();
const parseResult = await parse(documentText, offset);
if (parseResult.type === ParseResultType.Expression) {
const component = await this.search.getCurrentComponent();
if (!component) {
return;
}
const { xmlDocument, xmlNode } = parseXml(documentText, offset);
const componentText = await readFile(component.uri);
const virtualDocument = await this.getVirtualJsDocument(document.uri, component.componentName, componentText, xmlDocument, xmlNode, parseResult);
const definitions: any = await this.executeCommand(
Commands.Definition,
document.uri,
virtualDocument.content,
virtualDocument.offset
);
if (definitions.length > 0) {
const selectionRange = definitions[0].targetSelectionRange;
const range = new Range(
new Position(selectionRange.start.line, selectionRange.start.character),
new Position(selectionRange.end.line, selectionRange.end.character),
)
return new Location(component.uri, range);
}
} else if (parseResult.type === ParseResultType.Attribute) {
const { xmlNode } = parseXml(documentText, offset);
const childComponent = await this.search.findComponent(xmlNode.name);
if (!childComponent) {
return [];
}
const modifiersRegex = new RegExp([
"\\.bind",
"\\.stop",
"\\.prevent",
"\\.self",
"\\.capture",
"\\.sythetic",
].join("|"), "g");
let attributeName = getSelectedText(/\b[a-zA-Z0-9_\-.]+\b/, document, position)
attributeName = attributeName?.replace(modifiersRegex, "") ?? "";
const componentText = await readFile(childComponent.uri);
const content = `${componentText}\n${xmlNode.name}.props.${attributeName}`;
const definitions: any = await this.executeCommand(Commands.Definition, document.uri, content);
if (definitions.length > 0) {
const selectionRange = definitions[0].targetSelectionRange;
const range = new Range(
new Position(selectionRange.start.line, selectionRange.start.character),
new Position(selectionRange.end.line, selectionRange.end.character),
)
return new Location(childComponent.uri, range);
}
} else if (parseResult.type === ParseResultType.Element) {
const currentWord = getSelectedText(/<\/?[A-Z][a-zA-Z]+/, document, position);
if (!currentWord) {
return;
}
const componentName = currentWord.replace(/[\/<]/g, "").trim();
return await this.search.findComponent(componentName);
}
}
/**
* CompletionItemProvider interface implementation
*
* See {@link provideElementItems}, {@link provideAttributeItems} and {@link provideExpressionItems}
* for further details.
*/
async provideCompletionItems(
document: TextDocument,
position: Position,
token: CancellationToken,
context: CompletionContext
): Promise<CompletionItem[]> {
const component = await this.search.getCurrentComponent();
if (!component || token.isCancellationRequested) {
return [];
}
const offset = document.offsetAt(position);
const documentText = document.getText();
const parseResult = await parse(documentText, offset);
const { xmlDocument, xmlNode } = parseXml(documentText, offset);
if (parseResult.type === ParseResultType.Expression) {
return this.provideExpressionItems(document.uri, component.uri, component.componentName, xmlDocument, xmlNode, parseResult);
} else if (parseResult.type === ParseResultType.Attribute) {
return this.provideAttributeItems(document.uri, xmlNode);
} else if (parseResult.type === ParseResultType.Element) {
return this.provideElementItems(document.uri, component.uri, component.componentName, parseResult);
}
return [];
}
/**
* Returns the completion items for attributes.
* - Returns props if the element is a component
* - Returns the owl directives based on the element type
*/
private async provideAttributeItems(
documentUri: Uri,
xmlNode: any,
): Promise<CompletionItem[]> {
if (!xmlNode || xmlNode.name === xmlNode.name.toLowerCase()) {
return [...owlElementAttributes, ...events];
}
const childComponent = await this.search.findComponent(xmlNode.name);
if (!childComponent) {
return [];
}
const componentText = await readFile(childComponent.uri);
const content = `${componentText}\n${xmlNode.name}.props.`;
const list = await this.executeCommand(Commands.Completion, documentUri, content) as CompletionList;
const modifiersRegex = new RegExp([
"\\.bind",
"\\.stop",
"\\.prevent",
"\\.self",
"\\.capture",
"\\.sythetic",
].join("|"), "g");
const excludedAttrs = [
"slots",
...Object.keys(xmlNode.attr).map(attr => attr.replace(modifiersRegex, ""))
];
return mapCompletionItems(filterComponentItems([
...owlComponentAttributes,
...list.items
], excludedAttrs));
}
/**
* Returns the completion items for elements, this includes
* components, "t" and html elements.
*/
private async provideElementItems(
documentUri: Uri,
componentUri: Uri,
componentName: string,
parseResult: any,
): Promise<CompletionItem[]> {
const componentText = await readFile(componentUri);
const content = `${componentText}\n${componentName}.components.${parseResult.expression}`;
const list = await this.executeCommand(Commands.Completion, documentUri, content) as CompletionList;
return mapCompletionItems([
...elements,
...filterComponentItems(list.items),
]);
}
/**
* Returns the completion items for a js expression
*/
private async provideExpressionItems(
documentUri: Uri,
componentUri: Uri,
componentName: string,
xmlDocument: any,
xmlNode: any,
parseResult: ParseResult,
): Promise<CompletionItem[]> {
let { attributeName } = parseResult;
const dynamicAttributeRegex = new RegExp([
"t-if",
"t-elif",
"t-foreach",
"t-as",
"t-key",
"t-esc",
"t-out",
"t-props",
"t-component",
"t-set",
"t-value",
"t-portal",
"t-slot-scope",
"t-att",
"t-tag",
"t-log",
"t-model",
"t-att-[a-z_:.-]+",
"t-on-[a-z_:.-]+"
].join("|"));
if (xmlNode.name === xmlNode.name.toLowerCase() && !dynamicAttributeRegex.test(attributeName)) {
return [];
}
const componentText = await readFile(componentUri);
const virtualDocument = await this.getVirtualJsDocument(documentUri, componentName, componentText, xmlDocument, xmlNode, parseResult);
const completionList = await this.executeCommand(
Commands.Completion,
documentUri,
virtualDocument.content,
virtualDocument.offset
) as CompletionList;
let items = filterComponentItems(completionList.items, ["__VIRTUAL__", "setup"]);
if (!/\bthis\./.test(parseResult.expression)) {
items = items.map(item => {
let insertText = item.insertText as string;
if (/\bthis\./.test(insertText)) {
item.insertText = insertText.replace(/\bthis\./, "");
}
return item;
})
}
return mapCompletionItems([...items]);
}
/**
* Creates a virtual document to provide the appropriate
* completion items for a parsed js expression.
*
* This method:
* - Adds default owl variables such as env and props
* - Adds mocks for frequently used Owl imports (which cannot be resolved using commands)
* - Adds local variables generated from Owl xml directives such as t-for or t-set
* - Will try to add a "this." in front of the js expression if it was omitted so it can
* be understood by vscode typescript server.
*
* The expression offset is also modified accordingly.
*/
async getVirtualJsDocument(
documentUri: Uri,
componentName: string,
componentText: string,
xmlDocument: any,
xmlNode: any,
parseResult: ParseResult,
) {
const properties = await this.getComponentProperties(documentUri, componentName, componentText);
// As imports do not work, use mocks for frequently used owl functions.
let importReplacements = workspace.getConfiguration().get(`owl-vision.autocomplete-mocks`);
// As imports do not work, manually add "env" and "props"
// to the current component instance.
const localVariables = [
"let env = {};",
"this.env = env;",
`let props = ${componentName}.props;`,
`this.props = props;`,
];
// Adds local variables generated based on the template
const path = getNodePath(xmlDocument, xmlNode);
for (const node of path) {
if (node.attr["t-foreach"]) {
let array = contextualize(properties, node.attr["t-foreach"]).expression;
localVariables.push(`const ${node.attr["t-as"]} = ${array}[0];`);
localVariables.push(`const ${node.attr["t-as"]}_index = 0;`);
localVariables.push(`const ${node.attr["t-as"]}_first = ${array}[0];`);
localVariables.push(`const ${node.attr["t-as"]}_last = ${array}.at(-1);`);
localVariables.push(`const ${node.attr["t-as"]}_value = {};`);
} else if (node.attr["t-set"]) {
localVariables.push(`const ${node.attr["t-set"]} = ${node.attr["t-value"]};`);
}
}
const { expression, expressionOffset } = contextualize(properties, parseResult.expression, parseResult.expressionOffset);
return {
offset: expressionOffset,
content: `${componentText}
${importReplacements}
class __VIRTUAL__ extends ${componentName} { __VIRTUAL__() {
${localVariables.join("\n")}
${expression} }}`,
}
}
/**
* Returns the list of properties for a given component class.
* The result in cached in `componentProperties`.
*
* @param documentUri
* @param componentName
* @param componentText
* @returns
*/
async getComponentProperties(
documentUri: Uri,
componentName: string,
componentText: string,
): Promise<string[]> {
const check = hash(componentText);
let cached = this.componentProperties.get(componentName);
if (!cached || cached.check !== check) {
const contextExpression = `${componentText}\nclass __VIRTUAL__ extends ${componentName} { __VIRTUAL__() { \nthis. }}`;
const contextItemsCompletion = await this.executeCommand(Commands.Completion, documentUri, contextExpression, 5) as CompletionList;
const items = filterComponentItems(contextItemsCompletion.items, ["__VIRTUAL__", "setup"]).map(item => item.label);
cached = { check, items };
this.componentProperties.set(componentName, cached);
}
return cached.items;
}
private async executeCommand(commandId: string, uri: Uri, content: string, offset: any = undefined) {
const lines = content.split(/\r\n|\r|\n/);
const _offset = offset !== undefined ? offset : (lines.at(-1)?.length ?? 0);
const position = new Position(lines.length - 1, _offset);
const originalUri = uri.toString(true);
const hashValue = hash(content);
const id = `owl://js/${originalUri}_${hashValue}.js`;
this.virtualDocuments.set(id, content);
return await commands.executeCommand(
commandId,
Uri.parse(`owl://js/${encodeURIComponent(originalUri)}_${hashValue}.js`),
position
);
}
}
@@ -0,0 +1,153 @@
import { XmlDocument, XmlElement, XmlNode } from "xmldoc";
export enum ParseResultType {
Expression,
Attribute,
Element
}
export interface ParseResult {
type: ParseResultType;
expression: string;
expressionOffset: number;
attributeName: string
}
/**
* Will parse the document to find the selected expression based on an offset.
* The result can be on of three types:
*
* Element: The offset is on a element tag name, the expression is the current
* tagname or and empty string if it's just a opening tag.
*
* Attribute: The offset is inside the element but not in an attribute value,
* the expression is the current attribute name if any.
*
* Expression: The offset is inside an attribute value, the expression is the value.
*/
export async function parse(
documentText: string,
offset: number,
): Promise<ParseResult> {
// Check if the offset is preceded by "<xyz", if true returns a type Element
// with the current name.
const elementMatch = documentText.substring(0, offset).match(/<([a-zA-Z\-._]*)$/);
if (elementMatch) {
return {
type: ParseResultType.Element,
expression: elementMatch[1] || "",
expressionOffset: elementMatch[1].length || 0,
attributeName: "",
};
}
let {
value: expression,
offset: expressionOffset,
from,
} = getSection(documentText, offset, '="', '"');
// If the expression contains '"', it means we aren't inside an attribute
// value.
if (expression.includes('"')) {
const attributeMatch = documentText.substring(0, offset).match(/\s([a-zA-Z\-._]*)$/);
if (attributeMatch) {
return {
type: ParseResultType.Attribute,
expression: attributeMatch[1] || "",
expressionOffset: attributeMatch[1].length || 0,
attributeName: "",
};
}
}
let attributeName = "";
let i = from - 2;
while (/\S/.test(documentText[i])) {
attributeName = documentText[i] + attributeName;
i--;
}
return {
type: ParseResultType.Expression,
expression,
expressionOffset,
attributeName,
};
}
export function getSection(text: string, offset: number, prefix: string, postfix: string) {
const beforeText = text.substring(0, offset);
let from = beforeText.lastIndexOf(prefix);
const afterText = text.substring(offset);
const to = beforeText.length + afterText.indexOf(postfix);
from = from + (prefix.length);
return {
value: text.substring(from, to),
offset: offset - from,
from: from,
to,
};
}
/**
* Returns 2 xml nodes:
* xmlNode: Tries to create the current element based on a string offset,
* even if the node is invalid.
* xmlDocument: The document root element, only works if the document is
* valid xml.
*/
export function parseXml(text: string, offset: number): any {
let xmlDocument = undefined;
try {
xmlDocument = new XmlDocument(text);
} catch (error) { }
let i = 0;
while (text[offset + i] !== "<") {
i--;
}
let node = "";
while (text[offset + i] !== ">" || text[offset + i - 1] === "=") {
node += text[offset + i];
i++;
}
let xmlNode = undefined;
try {
xmlNode = new XmlDocument(`${node}${node.endsWith("/") ? '' : '/'}>`);
} catch (error) { }
return { xmlDocument, xmlNode };
}
/**
* Returns an array representing the elements order from the document's
* root to the specified element.
*/
export function getNodePath(xmlDocument: XmlDocument, xmlNode: any): Array<XmlElement> {
let path: Array<XmlElement> = [];
const traverse = (node: XmlElement, currentPath: Array<XmlElement>) => {
if (node.name === xmlNode.name && JSON.stringify(node.attr) === JSON.stringify(xmlNode.attr)) {
path = currentPath;
return;
}
if (node.children) {
for (const child of node.children) {
if (child instanceof XmlElement && child.name) {
traverse(child, currentPath.concat(child));
}
}
}
}
traverse(xmlDocument, []);
return path;
}
+46
View File
@@ -94,6 +94,31 @@ export class Search {
return await this.find(templateName, query, "xml");
}
public async getCurrentComponent(): Promise<any | undefined> {
if (!this.currentDocument) {
return;
}
const text = this.currentDocument.getText();
const templateName = this.getTemplateName(text, false);
if (templateName) {
const component = await this.findComponentFromTemplateName(templateName);
if (component) {
const componentFile = await workspace.fs.readFile(component.uri);
const componentText = Buffer.from(componentFile).toString('utf8');
const componentName = this.getComponentName(componentText, templateName);
return {
uri: component.uri,
templateName,
componentName,
};
}
}
}
private findComponentFromTemplateName(templateName: string): Promise<Location | undefined> {
const query = this.buildQuery(`template\\s*=\\s*["']`, templateName, `["']`);
return this.find(templateName, query, "js");
@@ -107,6 +132,27 @@ export class Search {
}
}
private getComponentName(str: string, templateName: string): string {
const templateNameRegex = new RegExp(`template\\s*=\\s*["'](${templateName})["']`, 'g');
const templateIndex = [...str.matchAll(templateNameRegex)][0]?.index ?? 0;
const matches = [...str.matchAll(new RegExp(`class\\s+([A-Za-z_]+)\\sextends\\s+[A-Za-z_]+`, 'g'))];
let result = "";
let currentIndex = -1;
for (const match of matches) {
if (match.index > templateIndex) {
continue;
}
if (match.index > currentIndex) {
result = match[1];
currentIndex = match.index;
}
}
return result;
}
public async find(
name: string,
searchQuery: string,
+17
View File
@@ -102,3 +102,20 @@ export async function showResult(result: vscode.Location, openDirection: OpenDir
editor.revealRange(result.range);
editor.selection = new vscode.Selection(result.range.start, result.range.end);
}
export async function readFile(uri: vscode.Uri): Promise<string> {
const data = await vscode.workspace.fs.readFile(uri);
return Buffer.from(data).toString('utf8');
}
export function hash(str: string) {
var hash = 0,
i, chr;
if (str.length === 0) return hash;
for (i = 0; i < str.length; i++) {
chr = str.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0;
}
return hash;
}
+4 -4
View File
@@ -160,7 +160,7 @@
"patterns": [
{
"contentName": "meta.embedded.block.javascript string.quoted.double.xml",
"begin": "(\\s*)(t-if|t-else|t-elif|t-foreach|t-as|t-key|t-esc|t-out|t-props|t-component|t-set|t-value|t-portal|t-slot-scope|t-att-[a-z_:.-]+|t-on-[a-z_:.-]+)(=)(\")",
"begin": "(\\s*)(t-if|t-elif|t-foreach|t-as|t-key|t-esc|t-out|t-props|t-component|t-set|t-value|t-portal|t-slot-scope|t-att|t-tag|t-log|t-model|t-att-[a-z_:.-]+|t-on-[a-z_:.-]+)(=)(\")",
"beginCaptures": {
"2": {
"name": "entity.other.attribute-name.localname.xml owl.attribute owl.attribute.dynamic"
@@ -183,7 +183,7 @@
},
{
"contentName": "meta.embedded.block.javascript string.quoted.single.xml",
"begin": "(\\s*)(t-if|t-else|t-elif|t-foreach|t-as|t-key|t-esc|t-out|t-props|t-component|t-set|t-value|t-portal|t-slot-scope|t-att-[a-z_:.-]+|t-on-[a-z_:.-]+)(=)(')",
"begin": "(\\s*)(t-if|t-elif|t-foreach|t-as|t-key|t-esc|t-out|t-props|t-component|t-set|t-value|t-portal|t-slot-scope|t-att|t-tag|t-log|t-model|t-att-[a-z_:.-]+|t-on-[a-z_:.-]+)(=)(')",
"beginCaptures": {
"2": {
"name": "entity.other.attribute-name.localname.xml owl.attribute owl.attribute.dynamic"
@@ -210,7 +210,7 @@
"patterns": [
{
"contentName": "string.quoted.double.xml",
"begin": "(\\s*)(t-name|t-ref|t-set-slot|t-model|t-inherit|t-inherit-mode|t-translation)(=)(\")",
"begin": "(\\s*)(t-name|t-else|t-ref|t-set-slot|t-inherit|t-inherit-mode|t-translation|t-debug)(=)(\")",
"beginCaptures": {
"2": {
"name": "entity.other.attribute-name.localname.xml owl.attribute owl.attribute.static"
@@ -229,7 +229,7 @@
},
{
"contentName": "string.quoted.single.xml",
"begin": "(\\s*)(t-name|t-ref|t-set-slot|t-model|t-inherit|t-inherit-mode|t-translation)(=)(')",
"begin": "(\\s*)(t-name|t-else|t-ref|t-set-slot|t-inherit|t-inherit-mode|t-translation|t-debug)(=)(')",
"beginCaptures": {
"2": {
"name": "entity.other.attribute-name.localname.xml owl.attribute owl.attribute.static"