diff --git a/.changeset/isml-vs-extension.md b/.changeset/isml-vs-extension.md new file mode 100644 index 00000000..cfc8cf8f --- /dev/null +++ b/.changeset/isml-vs-extension.md @@ -0,0 +1,5 @@ +--- +'b2c-vs-extension': minor +--- + +Add ISML language support: file associations for `.isml` and `.ds`, TextMate grammar for syntax highlighting (including embedded JavaScript inside `` and `${}` expressions), language configuration for comment toggling/bracket pairs/auto-closing, ~50 snippets for common ISML constructs (conditionals, loops, includes, decorators, Page Designer slots/regions, `Resource.msg`, `URLUtils.url`, `dw/system/Logger`, `Transaction.wrap`, `BasketMgr`, and more), automatic insertion of closing tags when typing `>` after an opening ISML tag, Emmet abbreviation expansion, document links (cmd-click on `template="..."` in ``/``/`` jumps to the resolved template across the cartridge path), and breakpoint support in `.isml` files for the B2C script debugger. diff --git a/packages/b2c-vs-extension/README.md b/packages/b2c-vs-extension/README.md index 1c3c2288..e0bb1f16 100644 --- a/packages/b2c-vs-extension/README.md +++ b/packages/b2c-vs-extension/README.md @@ -18,6 +18,8 @@ This README is the source of truth for repo-level developer info (build/watch, l - Log tailing into a dedicated output channel. - Page Designer Assistant webview (Storefront Next page generation). - B2C-DX Analytics — CIP/CCAC Query Builder, Tables Browser, curated reports, multi-realm support, saved-query library. +- ISML language support — syntax highlighting, language configuration (comments, brackets, auto-close), and snippets for `.isml` files. +- ISML language support — syntax highlighting, language configuration (comments, brackets, auto-close), snippets, automatic closing-tag insertion, and Emmet support for `.isml` and `.ds` files. See the [docs site](https://salesforcecommercecloud.github.io/b2c-developer-tooling/vscode-extension/features) for the full tour. diff --git a/packages/b2c-vs-extension/language/isml/isml.snippets.json b/packages/b2c-vs-extension/language/isml/isml.snippets.json new file mode 100644 index 00000000..983316bc --- /dev/null +++ b/packages/b2c-vs-extension/language/isml/isml.snippets.json @@ -0,0 +1,101 @@ +{ + "expression": { + "prefix": "${", + "body": ["\\${${1:expression}}"], + "description": "ISML expression placeholder" + }, + "resource-msg": { + "prefix": "resourcemsg", + "body": ["\\${Resource.msg('${1:key}', '${2:bundle}', null)}"], + "description": "Localized message via Resource.msg" + }, + "resource-msgf": { + "prefix": "resourcemsgf", + "body": ["\\${Resource.msgf('${1:key}', '${2:bundle}', null, ${3:args})}"], + "description": "Formatted localized message via Resource.msgf" + }, + "url": { + "prefix": "urlutilsurl", + "body": ["\\${URLUtils.url('${1:Controller-Action}'${2:, 'param', value})}"], + "description": "Build a URL via URLUtils.url" + }, + "https-url": { + "prefix": "urlutilshttps", + "body": ["\\${URLUtils.https('${1:Controller-Action}'${2:, 'param', value})}"], + "description": "Build an HTTPS URL via URLUtils.https" + }, + "abs-url": { + "prefix": "urlutilsabs", + "body": ["\\${URLUtils.abs('${1:Controller-Action}'${2:, 'param', value})}"], + "description": "Build an absolute URL via URLUtils.abs" + }, + "static-url": { + "prefix": "urlutilsstatic", + "body": ["\\${URLUtils.staticURL('/${1:images/example.png}')}"], + "description": "Reference a static asset via URLUtils.staticURL" + }, + "request-locale": { + "prefix": "requestlocale", + "body": ["\\${request.locale}"], + "description": "Current request locale" + }, + "request-httpparam": { + "prefix": "requesthttpparam", + "body": ["\\${request.httpParameterMap.${1:paramName}.stringValue}"], + "description": "Read a query/form parameter" + }, + "pdict": { + "prefix": "pdict", + "body": ["\\${pdict.${1:property}}"], + "description": "Read from the pipeline dictionary (pdict)" + }, + "customer-logged": { + "prefix": "customerlogged", + "body": ["\\${customer.authenticated && customer.registered}"], + "description": "Condition for 'is the customer logged in'" + }, + "current-basket": { + "prefix": "currentbasket", + "body": [ + "", + "\tvar BasketMgr = require('dw/order/BasketMgr');", + "\tvar basket = BasketMgr.getCurrentBasket();", + "\t$0", + "" + ], + "description": "Get the current basket via BasketMgr" + }, + "current-customer": { + "prefix": "currentcustomer", + "body": [ + "", + "\tvar CustomerMgr = require('dw/customer/CustomerMgr');", + "\tvar customer = CustomerMgr.getCustomerByLogin('${1:login}');", + "\t$0", + "" + ], + "description": "Look up a customer via CustomerMgr" + }, + "current-site": { + "prefix": "currentsite", + "body": [ + "", + "\tvar Site = require('dw/system/Site');", + "\tvar site = Site.getCurrent();", + "\t$0", + "" + ], + "description": "Get the current Site" + }, + "logger": { + "prefix": "dwlogger", + "body": [ + "", + "\tvar Logger = require('dw/system/Logger');", + "\tvar log = Logger.getLogger('${1:category}', '${2:filename}');", + "\tlog.info('${3:message}');", + "" + ], + "description": "Acquire a dw/system/Logger and log a message" + } +} diff --git a/packages/b2c-vs-extension/language/isml/isml.tmLanguage.json b/packages/b2c-vs-extension/language/isml/isml.tmLanguage.json new file mode 100644 index 00000000..bacfeb35 --- /dev/null +++ b/packages/b2c-vs-extension/language/isml/isml.tmLanguage.json @@ -0,0 +1,169 @@ +{ + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", + "name": "ISML", + "scopeName": "text.html.isml", + "fileTypes": ["isml"], + "patterns": [ + {"include": "#isml-comment"}, + {"include": "#isml-script"}, + {"include": "#isml-tag"}, + {"include": "#isml-expression"}, + {"include": "text.html.basic"} + ], + "repository": { + "isml-comment": { + "name": "comment.block.isml", + "begin": "", + "end": "", + "beginCaptures": { + "0": {"name": "punctuation.definition.comment.begin.isml"} + }, + "endCaptures": { + "0": {"name": "punctuation.definition.comment.end.isml"} + } + }, + "isml-script": { + "name": "meta.embedded.block.isml.isscript", + "begin": "(<)(isscript)\\b([^>]*)(>)", + "end": "()", + "beginCaptures": { + "1": {"name": "punctuation.definition.tag.begin.isml"}, + "2": {"name": "entity.name.tag.isml"}, + "3": {"patterns": [{"include": "#tag-attributes"}]}, + "4": {"name": "punctuation.definition.tag.end.isml"} + }, + "endCaptures": { + "1": {"name": "punctuation.definition.tag.begin.isml"}, + "2": {"name": "entity.name.tag.isml"}, + "3": {"name": "punctuation.definition.tag.end.isml"} + }, + "patterns": [{"include": "source.js"}] + }, + "isml-expression": { + "name": "meta.embedded.expression.isml", + "begin": "\\$\\{", + "end": "\\}", + "beginCaptures": { + "0": {"name": "punctuation.section.embedded.begin.isml"} + }, + "endCaptures": { + "0": {"name": "punctuation.section.embedded.end.isml"} + }, + "patterns": [ + {"include": "#expression-content"} + ] + }, + "expression-content": { + "patterns": [ + { + "name": "string.quoted.double.isml", + "begin": "\"", + "end": "\"", + "patterns": [{"name": "constant.character.escape.isml", "match": "\\\\."}] + }, + { + "name": "string.quoted.single.isml", + "begin": "'", + "end": "'", + "patterns": [{"name": "constant.character.escape.isml", "match": "\\\\."}] + }, + { + "name": "constant.numeric.isml", + "match": "\\b\\d+(\\.\\d+)?\\b" + }, + { + "name": "constant.language.isml", + "match": "\\b(true|false|null|undefined)\\b" + }, + { + "name": "keyword.operator.isml", + "match": "(===|!==|==|!=|<=|>=|&&|\\|\\||[+\\-*/%<>!?:])" + }, + { + "name": "variable.other.isml", + "match": "\\b([a-zA-Z_$][\\w$]*)\\b" + } + ] + }, + "isml-tag": { + "patterns": [ + {"include": "#isml-tag-with-content"}, + {"include": "#isml-self-closing-tag"} + ] + }, + "isml-self-closing-tag": { + "name": "meta.tag.isml", + "begin": "()", + "beginCaptures": { + "1": {"name": "punctuation.definition.tag.begin.isml"}, + "2": {"name": "entity.name.tag.isml"} + }, + "endCaptures": { + "1": {"name": "punctuation.definition.tag.end.isml"} + }, + "patterns": [ + {"include": "#tag-attributes"} + ] + }, + "isml-tag-with-content": { + "patterns": [] + }, + "tag-attributes": { + "patterns": [ + { + "name": "meta.attribute.isml", + "begin": "([a-zA-Z_:][\\w:.-]*)\\s*(=)\\s*", + "end": "(?<=\"|')|(?=[\\s/>])", + "beginCaptures": { + "1": {"name": "entity.other.attribute-name.isml"}, + "2": {"name": "punctuation.separator.key-value.isml"} + }, + "patterns": [ + {"include": "#attribute-value"} + ] + }, + { + "name": "entity.other.attribute-name.isml", + "match": "\\b[a-zA-Z_:][\\w:.-]*\\b" + } + ] + }, + "attribute-value": { + "patterns": [ + { + "name": "string.quoted.double.isml", + "begin": "\"", + "end": "\"", + "beginCaptures": { + "0": {"name": "punctuation.definition.string.begin.isml"} + }, + "endCaptures": { + "0": {"name": "punctuation.definition.string.end.isml"} + }, + "patterns": [ + {"include": "#isml-expression"} + ] + }, + { + "name": "string.quoted.single.isml", + "begin": "'", + "end": "'", + "beginCaptures": { + "0": {"name": "punctuation.definition.string.begin.isml"} + }, + "endCaptures": { + "0": {"name": "punctuation.definition.string.end.isml"} + }, + "patterns": [ + {"include": "#isml-expression"} + ] + }, + { + "name": "string.unquoted.isml", + "match": "[^\\s'\"=<>`]+" + } + ] + } + } +} diff --git a/packages/b2c-vs-extension/language/isml/language-configuration.json b/packages/b2c-vs-extension/language/isml/language-configuration.json new file mode 100644 index 00000000..4195693a --- /dev/null +++ b/packages/b2c-vs-extension/language/isml/language-configuration.json @@ -0,0 +1,47 @@ +{ + "comments": { + "blockComment": ["", ""] + }, + "brackets": [ + [""], + ["<", ">"], + ["{", "}"], + ["(", ")"], + ["[", "]"] + ], + "autoClosingPairs": [ + {"open": "{", "close": "}"}, + {"open": "[", "close": "]"}, + {"open": "(", "close": ")"}, + {"open": "'", "close": "'"}, + {"open": "\"", "close": "\""}, + {"open": "", "notIn": ["comment", "string"]}, + {"open": "${", "close": "}", "notIn": ["comment"]} + ], + "surroundingPairs": [ + ["'", "'"], + ["\"", "\""], + ["{", "}"], + ["[", "]"], + ["(", ")"], + ["<", ">"] + ], + "wordPattern": "(-?\\d*\\.\\d\\w*)|([^\\`\\~\\!\\@\\$\\^\\&\\*\\(\\)\\=\\+\\[\\{\\]\\}\\\\\\|\\;\\:\\'\\\"\\,\\.\\<\\>\\/\\?\\s]+)", + "onEnterRules": [ + { + "beforeText": "<(?!(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr|isif|iselse|iselseif|isloop|isscript|iscomment|iscontent|isdecorate|isinclude|isset|ismodule|isprint|isredirect|isobject|isactivedatacontext|isactivedatahead|isanalyticsoff|isbreak|iscache|iscontinue|iscookies|isnext|isobject|isstatus|isslot)\\b)([_:\\w][_:\\w-.\\d]*)([^/>]*(?!/)>)[^<]*$", + "afterText": "^", + "action": {"indent": "indentOutdent"} + }, + { + "beforeText": "<(?!(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr)\\b)([_:\\w][_:\\w-.\\d]*)([^/>]*(?!/)>)[^<]*$", + "action": {"indent": "indent"} + } + ], + "folding": { + "markers": { + "start": "^\\s*", + "end": "^\\s*" + } + } +} diff --git a/packages/b2c-vs-extension/package.json b/packages/b2c-vs-extension/package.json index a98d8649..e0d06b65 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -17,6 +17,8 @@ "@salesforce/b2c-tooling-sdk": "workspace:*", "react": "18.3.1", "react-dom": "18.3.1" + "@salesforce/b2c-tooling-sdk": "workspace:*", + "vscode-html-languageservice": "catalog:" }, "engines": { "vscode": "^1.105.1" @@ -45,6 +47,59 @@ "configNamespace": "b2c-dx" } ], + "languages": [ + { + "id": "isml", + "aliases": ["ISML", "isml"], + "extensions": [".isml", ".ds"], + "configuration": "./language/isml/language-configuration.json" + } + ], + "configurationDefaults": { + "[isml]": { + "editor.linkedEditing": true, + "editor.suggest.insertMode": "replace", + "editor.wordWrap": "off" + }, + "[json]": { + "editor.quickSuggestions": { + "other": true, + "comments": false, + "strings": true + } + }, + "[jsonc]": { + "editor.quickSuggestions": { + "other": true, + "comments": false, + "strings": true + } + }, + "emmet.includeLanguages": { + "isml": "html" + } + }, + "grammars": [ + { + "language": "isml", + "scopeName": "text.html.isml", + "path": "./language/isml/isml.tmLanguage.json", + "embeddedLanguages": { + "meta.embedded.block.isml.isscript": "javascript", + "meta.embedded.expression.isml": "javascript" + } + } + ], + "snippets": [ + { + "language": "isml", + "path": "./language/isml/isml.snippets.json" + } + ], + "breakpoints": [ + {"language": "isml"}, + {"language": "javascript"} + ], "configuration": { "title": "B2C DX", "properties": { @@ -704,6 +759,16 @@ "title": "Refresh Script API IntelliSense", "icon": "$(refresh)", "category": "B2C DX" + }, + { + "command": "b2c-dx.isml.createTemplate", + "title": "Create ISML Template", + "category": "B2C DX - ISML" + }, + { + "command": "b2c-dx.isml.showReferences", + "title": "Show ISML References", + "category": "B2C DX - ISML" } ], "menus": { @@ -1028,6 +1093,10 @@ } ], "commandPalette": [ + { + "command": "b2c-dx.isml.createTemplate", + "when": "false" + }, { "command": "b2c-dx.cipAnalytics.refresh", "when": "config.b2c-dx.features.cipAnalytics" diff --git a/packages/b2c-vs-extension/src/extension.ts b/packages/b2c-vs-extension/src/extension.ts index 2eb5a7ab..52f284ad 100644 --- a/packages/b2c-vs-extension/src/extension.ts +++ b/packages/b2c-vs-extension/src/extension.ts @@ -22,6 +22,7 @@ import {registerScaffold} from './scaffold/index.js'; import {registerApiBrowser} from './api-browser/index.js'; import {registerDebugger} from './debugger/index.js'; import {registerCodeSync} from './code-sync/index.js'; +import {registerIsml} from './isml/index.js'; import {registerScriptTypes} from './script-types/index.js'; import {registerWebDavTree} from './webdav-tree/index.js'; import {disposeTelemetry, initTelemetry, markFeatureUsed, sendEvent, sendException} from './telemetry.js'; @@ -440,6 +441,8 @@ async function activateInner(context: vscode.ExtensionContext, log: vscode.Outpu registerCipAnalytics(context, configProvider, log); } + registerIsml(context, cartridgeService); + registerDebugger(context, configProvider); // React to configuration changes diff --git a/packages/b2c-vs-extension/src/isml/constants.ts b/packages/b2c-vs-extension/src/isml/constants.ts new file mode 100644 index 00000000..e241de04 --- /dev/null +++ b/packages/b2c-vs-extension/src/isml/constants.ts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +export const VOID_TAGS = new Set([ + 'iselse', + 'iselseif', + 'isnext', + 'isbreak', + 'iscontinue', + 'isinclude', + 'isset', + 'isprint', + 'isredirect', + 'isreplace', + 'iscontent', + 'iscache', + 'isstatus', + 'isactivedatahead', + 'isactivedatacontext', + 'isanalyticsoff', + 'isslot', + 'ismodule', + 'iscookies', +]); diff --git a/packages/b2c-vs-extension/src/isml/diagnostics.ts b/packages/b2c-vs-extension/src/isml/diagnostics.ts new file mode 100644 index 00000000..f88ab0c4 --- /dev/null +++ b/packages/b2c-vs-extension/src/isml/diagnostics.ts @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {VOID_TAGS} from './constants.js'; +import {scanIsmlTags} from './tags.js'; + +export type IsmlDiagnosticSeverity = 'error' | 'warning'; + +export interface IsmlDiagnostic { + message: string; + startOffset: number; + endOffset: number; + severity: IsmlDiagnosticSeverity; +} + +export interface IsmlQuickFixEdit { + startOffset: number; + endOffset: number; + newText: string; +} + +export interface IsmlQuickFix { + title: string; + edits: IsmlQuickFixEdit[]; +} + +interface OpenTag { + name: string; + startOffset: number; + endOffset: number; +} + +export function collectIsmlDiagnostics(text: string): IsmlDiagnostic[] { + const diagnostics: IsmlDiagnostic[] = []; + const stack: OpenTag[] = []; + + for (const token of scanIsmlTags(text)) { + if (!token.isClosing) { + if (VOID_TAGS.has(token.name) && !token.isSelfClosing) { + diagnostics.push({ + message: `ISML void tag <${token.name}> should be self-closing.`, + startOffset: token.startOffset, + endOffset: token.endOffset, + severity: 'warning', + }); + } + + if (!token.isSelfClosing && !VOID_TAGS.has(token.name)) { + stack.push({name: token.name, startOffset: token.startOffset, endOffset: token.endOffset}); + } + continue; + } + + if (VOID_TAGS.has(token.name)) { + diagnostics.push({ + message: `ISML void tag is not valid.`, + startOffset: token.startOffset, + endOffset: token.endOffset, + severity: 'error', + }); + continue; + } + + if (stack.length === 0) { + diagnostics.push({ + message: `Unexpected closing tag .`, + startOffset: token.startOffset, + endOffset: token.endOffset, + severity: 'error', + }); + continue; + } + + const matchingIndex = stack.map((item) => item.name).lastIndexOf(token.name); + if (matchingIndex < 0) { + diagnostics.push({ + message: `Unexpected closing tag .`, + startOffset: token.startOffset, + endOffset: token.endOffset, + severity: 'error', + }); + continue; + } + + if (matchingIndex !== stack.length - 1) { + const expected = stack[stack.length - 1]; + diagnostics.push({ + message: `Mismatched closing tag . Expected .`, + startOffset: token.startOffset, + endOffset: token.endOffset, + severity: 'error', + }); + } + + for (let i = stack.length - 1; i > matchingIndex; i--) { + const unclosed = stack[i]; + diagnostics.push({ + message: `Tag <${unclosed.name}> is not closed.`, + startOffset: unclosed.startOffset, + endOffset: unclosed.endOffset, + severity: 'error', + }); + } + + stack.length = matchingIndex; + } + + for (const unclosed of stack) { + diagnostics.push({ + message: `Tag <${unclosed.name}> is not closed.`, + startOffset: unclosed.startOffset, + endOffset: unclosed.endOffset, + severity: 'error', + }); + } + + return diagnostics; +} + +function extractTagName(message: string, pattern: RegExp): string | undefined { + const match = pattern.exec(message); + return match?.[1]; +} + +function makeSelfClosingTagSnippet(tagSource: string): string | undefined { + if (!tagSource.endsWith('>') || /\/\s*>$/.test(tagSource)) return undefined; + + const gtIndex = tagSource.lastIndexOf('>'); + let insertIndex = gtIndex; + while (insertIndex > 0 && /\s/.test(tagSource[insertIndex - 1])) { + insertIndex--; + } + + return `${tagSource.slice(0, insertIndex)}/${tagSource.slice(insertIndex)}`; +} + +export function getIsmlQuickFixes(text: string, diagnostic: IsmlDiagnostic): IsmlQuickFix[] { + const shouldSelfCloseTagName = extractTagName( + diagnostic.message, + /^ISML void tag <([a-z][\w-]*)> should be self-closing\.$/i, + ); + if (shouldSelfCloseTagName) { + const source = text.slice(diagnostic.startOffset, diagnostic.endOffset); + const replacement = makeSelfClosingTagSnippet(source); + if (!replacement) return []; + + return [ + { + title: `Make <${shouldSelfCloseTagName}> self-closing`, + edits: [{startOffset: diagnostic.startOffset, endOffset: diagnostic.endOffset, newText: replacement}], + }, + ]; + } + + const invalidVoidClosingTagName = extractTagName( + diagnostic.message, + /^ISML void tag <\/([a-z][\w-]*)> is not valid\.$/i, + ); + if (invalidVoidClosingTagName) { + return [ + { + title: `Replace with <${invalidVoidClosingTagName}/>`, + edits: [ + { + startOffset: diagnostic.startOffset, + endOffset: diagnostic.endOffset, + newText: `<${invalidVoidClosingTagName}/>`, + }, + ], + }, + ]; + } + + return []; +} diff --git a/packages/b2c-vs-extension/src/isml/document-links.ts b/packages/b2c-vs-extension/src/isml/document-links.ts new file mode 100644 index 00000000..50df5ecf --- /dev/null +++ b/packages/b2c-vs-extension/src/isml/document-links.ts @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import * as fs from 'fs'; +import * as path from 'path'; + +import {scanIsml} from './scanner.js'; + +const TEMPLATE_TAGS = new Set(['isinclude', 'isdecorate', 'ismodule']); + +export interface TemplateLink { + template: string; + startOffset: number; + endOffset: number; +} + +/** + * Find every `template="..."` attribute on ``, ``, or `` + * in the given document text. Returned offsets cover the unquoted template path so the + * resulting link only underlines the path itself, not the quotes. + */ +export function findTemplateLinks(text: string): TemplateLink[] { + const links: TemplateLink[] = []; + + let currentTagName: string | null = null; + let currentAttributeName: string | null = null; + + scanIsml(text, (token) => { + if (token.type === 'startTag') { + currentTagName = token.text.toLowerCase(); + currentAttributeName = null; + return; + } + + if (token.type === 'startTagClose' || token.type === 'startTagSelfClose') { + currentTagName = null; + currentAttributeName = null; + return; + } + + if (token.type === 'attributeName') { + currentAttributeName = token.text.toLowerCase(); + return; + } + + if (token.type !== 'attributeValue') return; + + if (!currentTagName || !TEMPLATE_TAGS.has(currentTagName)) return; + if (currentAttributeName !== 'template') return; + + const rawValue = token.text; + let valueStart = token.offset; + let valueEnd = token.offset + token.length; + let value = rawValue; + + if (rawValue.length >= 2) { + const first = rawValue[0]; + const last = rawValue[rawValue.length - 1]; + if ((first === '"' || first === "'") && first === last) { + valueStart++; + valueEnd--; + value = rawValue.slice(1, -1); + } + } + + if (value.length === 0) return; + if (value.startsWith('$') || value.startsWith('${')) return; + + links.push({template: value, startOffset: valueStart, endOffset: valueEnd}); + currentAttributeName = null; + }); + + return links; +} + +/** + * Resolve a template reference to an absolute file path on disk by searching + * each cartridge's `cartridge/templates//...isml` tree, in cartridge-path order. + * + * Templates without an `.isml` extension automatically have one appended. + * Locale defaults to `default` plus any directories under `cartridge/templates` + * that exist on disk (e.g. `en_US`, `fr_FR`). + */ +export function resolveTemplate(template: string, cartridgeRoots: string[]): string | undefined { + const trimmed = template.replace(/^\/+/, ''); + const withExt = trimmed.endsWith('.isml') ? trimmed : `${trimmed}.isml`; + + for (const root of cartridgeRoots) { + const templatesRoot = path.join(root, 'cartridge', 'templates'); + let locales: string[]; + try { + locales = fs + .readdirSync(templatesRoot, {withFileTypes: true}) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + } catch { + continue; + } + const ordered = ['default', ...locales.filter((l) => l !== 'default')]; + for (const locale of ordered) { + const candidate = path.join(templatesRoot, locale, withExt); + try { + const stat = fs.statSync(candidate); + if (stat.isFile()) return candidate; + } catch { + // not found in this locale, keep looking + } + } + } + return undefined; +} diff --git a/packages/b2c-vs-extension/src/isml/folding.ts b/packages/b2c-vs-extension/src/isml/folding.ts new file mode 100644 index 00000000..3ba74d90 --- /dev/null +++ b/packages/b2c-vs-extension/src/isml/folding.ts @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {VOID_TAGS} from './constants.js'; +import {scanIsmlTags, type IsmlTagToken} from './tags.js'; + +export interface IsmlFoldingRange { + startOffset: number; + endOffset: number; +} + +export function collectIsmlFoldingRanges(text: string): IsmlFoldingRange[] { + const ranges: IsmlFoldingRange[] = []; + const stack: IsmlTagToken[] = []; + + for (const token of scanIsmlTags(text)) { + if (!token.isClosing) { + if (!token.isSelfClosing && !VOID_TAGS.has(token.name)) { + stack.push(token); + } + continue; + } + + if (VOID_TAGS.has(token.name)) continue; + + const matchingIndex = stack.map((entry) => entry.name).lastIndexOf(token.name); + if (matchingIndex < 0) continue; + + for (let i = stack.length - 1; i >= matchingIndex; i--) { + const opening = stack[i]; + ranges.push({ + startOffset: opening.startOffset, + endOffset: token.endOffset, + }); + stack.pop(); + } + } + + return ranges; +} diff --git a/packages/b2c-vs-extension/src/isml/hover.ts b/packages/b2c-vs-extension/src/isml/hover.ts new file mode 100644 index 00000000..97328761 --- /dev/null +++ b/packages/b2c-vs-extension/src/isml/hover.ts @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {scanIsmlTags} from './tags.js'; + +export interface IsmlHoverInfo { + tagName: string; + summary: string; + syntax: string; + attributes: string[]; + tips: string[]; + isClosing: boolean; + isSelfClosing: boolean; +} + +interface IsmlHoverDoc { + summary: string; + syntax: string; + attributes?: string[]; + tips?: string[]; +} + +const HOVER_DOCS: Record = { + isif: { + summary: 'Conditional block. Renders body when `condition` evaluates to true.', + syntax: '...', + attributes: ['condition'], + tips: ['Use and for additional branches.'], + }, + iselseif: { + summary: 'Branch condition inside an `isif` block.', + syntax: '', + attributes: ['condition'], + }, + iselse: { + summary: 'Fallback branch for an `isif` block.', + syntax: '', + }, + isloop: { + summary: 'Iterates over a collection or iterator.', + syntax: '...', + attributes: ['items', 'var', 'status', 'begin', 'end', 'step'], + }, + isinclude: { + summary: 'Includes another template by `template` or `url` attribute.', + syntax: '', + attributes: ['template', 'url', 'sf-toolkit'], + tips: ['Use `template` for local includes and `url` for remote includes.'], + }, + isdecorate: { + summary: 'Wraps current output in a decorator template.', + syntax: '...', + attributes: ['template'], + }, + ismodule: { + summary: 'Declares a custom ISML tag module.', + syntax: '', + attributes: ['template', 'name', 'attribute'], + }, + isslot: { + summary: 'Renders a content slot by `id` and optional context attributes.', + syntax: '', + attributes: ['id', 'description', 'context', 'context-object'], + }, + isscript: { + summary: 'Server-side script block in Demandware Script.', + syntax: '\n var helper = require("*/cartridge/scripts/util/foo");\n', + tips: ['Use this for server-side logic like `require(...)` and `res.render(...)`.'], + }, + isprint: { + summary: 'Prints a value with optional encoding or formatter.', + syntax: '', + attributes: ['value', 'encoding', 'formatter', 'timezone'], + }, + isset: { + summary: 'Assigns a variable in page/request/session scope.', + syntax: '', + attributes: ['name', 'value', 'scope'], + }, + iscontent: { + summary: 'Sets response content metadata such as type and charset.', + syntax: '', + attributes: ['type', 'charset', 'compact'], + }, + iscomment: { + summary: 'ISML comment block not rendered in output.', + syntax: 'internal note', + }, +}; + +export function findIsmlHoverInfo(text: string, offset: number): IsmlHoverInfo | undefined { + for (const token of scanIsmlTags(text)) { + if (offset < token.nameStartOffset || offset > token.nameEndOffset) continue; + const doc = HOVER_DOCS[token.name]; + if (!doc) return undefined; + return { + tagName: token.name, + summary: doc.summary, + syntax: doc.syntax, + attributes: doc.attributes ?? [], + tips: doc.tips ?? [], + isClosing: token.isClosing, + isSelfClosing: token.isSelfClosing, + }; + } + return undefined; +} diff --git a/packages/b2c-vs-extension/src/isml/index.ts b/packages/b2c-vs-extension/src/isml/index.ts new file mode 100644 index 00000000..47914ebf --- /dev/null +++ b/packages/b2c-vs-extension/src/isml/index.ts @@ -0,0 +1,607 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import * as vscode from 'vscode'; + +import type {CartridgeService} from '../cartridges/cartridge-service.js'; +import {VOID_TAGS} from './constants.js'; +import {collectIsmlDiagnostics, getIsmlQuickFixes} from './diagnostics.js'; +import {findTemplateLinks, resolveTemplate} from './document-links.js'; +import {collectIsmlFoldingRanges} from './folding.js'; +import {findIsmlHoverInfo} from './hover.js'; +import {findIsmlLinkedEditingTagNameMatch, findIsmlTagNameMatch} from './matching.js'; +import { + detectSemanticCompletionContext, + findIsmlDefinitionTarget, + findIsmlReferenceRanges, + getSemanticCompletionEntries, +} from './semantic.js'; +import {TAG_SNIPPETS, detectCompletionContext} from './snippets.js'; +import {collectIsmlSymbols, type IsmlSymbol} from './symbols.js'; + +export interface AutoCloseResult { + tagName: string; +} + +function registerSemanticCompletions(context: vscode.ExtensionContext, cartridgeService: CartridgeService): void { + const provider: vscode.CompletionItemProvider = { + provideCompletionItems(document, position) { + if (document.languageId !== 'isml') return undefined; + + const linePrefix = document.getText(new vscode.Range(new vscode.Position(position.line, 0), position)); + const semantic = detectSemanticCompletionContext(linePrefix); + if (!semantic) return undefined; + + const replaceStart = new vscode.Position(position.line, semantic.startOffset); + const range = new vscode.Range(replaceStart, position); + + return getSemanticCompletionEntries(semantic, cartridgeService.getCartridgeRoots(), document.uri.fsPath).map( + (entry) => { + const item = new vscode.CompletionItem(entry.label, vscode.CompletionItemKind.Method); + item.insertText = entry.insertText; + item.detail = entry.detail; + item.range = range; + item.sortText = `0_${entry.label}`; + return item; + }, + ); + }, + }; + + context.subscriptions.push( + vscode.languages.registerCompletionItemProvider( + {language: 'isml'}, + provider, + '.', + 'm', + 'u', + 'r', + '/', + '~', + '*', + '"', + "'", + ',', + ), + ); +} + +function registerCodeActions(context: vscode.ExtensionContext): void { + const provider: vscode.CodeActionProvider = { + provideCodeActions(document, _range, codeActionContext) { + if (document.languageId !== 'isml') return []; + + const text = document.getText(); + const actions: vscode.CodeAction[] = []; + + for (const diagnostic of codeActionContext.diagnostics) { + if (diagnostic.source !== 'b2c-dx-isml') continue; + + let payload: {startOffset: number; endOffset: number; message: string} = { + startOffset: document.offsetAt(diagnostic.range.start), + endOffset: document.offsetAt(diagnostic.range.end), + message: diagnostic.message, + }; + + if (typeof diagnostic.code === 'string') { + try { + const parsed = JSON.parse(diagnostic.code) as {startOffset?: number; endOffset?: number; message?: string}; + if ( + typeof parsed.startOffset === 'number' && + typeof parsed.endOffset === 'number' && + typeof parsed.message === 'string' + ) { + payload = { + startOffset: parsed.startOffset, + endOffset: parsed.endOffset, + message: parsed.message, + }; + } + } catch { + // fall back to range/message payload + } + } + + const quickFixes = getIsmlQuickFixes(text, { + message: payload.message, + startOffset: payload.startOffset, + endOffset: payload.endOffset, + severity: diagnostic.severity === vscode.DiagnosticSeverity.Warning ? 'warning' : 'error', + }); + + for (const quickFix of quickFixes) { + const action = new vscode.CodeAction(quickFix.title, vscode.CodeActionKind.QuickFix); + const edit = new vscode.WorkspaceEdit(); + for (const quickEdit of quickFix.edits) { + edit.replace( + document.uri, + new vscode.Range(document.positionAt(quickEdit.startOffset), document.positionAt(quickEdit.endOffset)), + quickEdit.newText, + ); + } + action.edit = edit; + action.diagnostics = [diagnostic]; + action.isPreferred = true; + actions.push(action); + } + } + + return actions; + }, + }; + + context.subscriptions.push( + vscode.languages.registerCodeActionsProvider({language: 'isml'}, provider, { + providedCodeActionKinds: [vscode.CodeActionKind.QuickFix], + }), + ); +} + +/** + * Decide whether typing `>` after the given line content should auto-insert a closing tag. + * `linePrefixIncludingBracket` must include the just-typed `>` as its final character. + */ +export function detectAutoCloseTag(linePrefixIncludingBracket: string): AutoCloseResult | null { + if (!linePrefixIncludingBracket.endsWith('>')) return null; + + const beforeBracket = linePrefixIncludingBracket.slice(0, -1); + const lastOpen = beforeBracket.lastIndexOf('<'); + if (lastOpen < 0) return null; + + const tagBody = beforeBracket.slice(lastOpen + 1); + if (!tagBody || tagBody.startsWith('/')) return null; + + const first = tagBody.match(/^\s*(is[a-zA-Z][\w-]*)\b/); + if (!first) return null; + + const tagName = first[1]; + const rest = tagBody.slice(first[0].length); + + let quote: '"' | "'" | null = null; + for (let i = 0; i < rest.length; i++) { + const ch = rest[i]; + if (quote) { + if (ch === quote) quote = null; + } else if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '<' || ch === '>') { + return null; + } + } + if (quote) return null; + + if (/\/\s*$/.test(rest)) return null; + if (VOID_TAGS.has(tagName.toLowerCase())) return null; + return {tagName}; +} + +function registerAutoCloseTag(context: vscode.ExtensionContext): void { + const disposable = vscode.workspace.onDidChangeTextDocument(async (event) => { + if (event.document.languageId !== 'isml') return; + if (event.contentChanges.length !== 1) return; + + const change = event.contentChanges[0]; + if (change.text !== '>') return; + + const editor = vscode.window.activeTextEditor; + if (!editor || editor.document !== event.document) return; + + const insertedAt = change.range.start; + const positionAfterBracket = insertedAt.translate(0, 1); + + const linePrefix = event.document.getText( + new vscode.Range(new vscode.Position(insertedAt.line, 0), positionAfterBracket), + ); + + const result = detectAutoCloseTag(linePrefix); + if (!result) return; + + const closing = ``; + + const ok = await editor.edit( + (edit) => { + edit.insert(positionAfterBracket, closing); + }, + {undoStopBefore: false, undoStopAfter: false}, + ); + + if (ok) { + const cursor = positionAfterBracket; + editor.selection = new vscode.Selection(cursor, cursor); + } + }); + + context.subscriptions.push(disposable); +} + +function registerTagCompletions(context: vscode.ExtensionContext): void { + const provider: vscode.CompletionItemProvider = { + provideCompletionItems(document, position) { + if (document.languageId !== 'isml') return undefined; + + const linePrefix = document.getText(new vscode.Range(new vscode.Position(position.line, 0), position)); + const ctx = detectCompletionContext(linePrefix); + if (!ctx) return undefined; + + const typedPartial = ctx.partial.replace(/^ snippet.prefix.toLowerCase().startsWith(typedPartial)).map((snippet) => { + const item = new vscode.CompletionItem(snippet.prefix, vscode.CompletionItemKind.Snippet); + const body = snippet.body.join('\n'); + item.insertText = new vscode.SnippetString(body); + item.detail = snippet.description; + item.documentation = new vscode.MarkdownString().appendCodeblock(body, 'isml'); + item.range = range; + item.filterText = ctx.hasLeadingBracket ? `<${snippet.prefix}` : snippet.prefix; + item.sortText = snippet.prefix; + return item; + }); + }, + }; + + context.subscriptions.push( + vscode.languages.registerCompletionItemProvider({language: 'isml'}, provider, '<', 'i', 's'), + ); +} + +function registerDocumentLinks(context: vscode.ExtensionContext, cartridgeService: CartridgeService): void { + const provider: vscode.DocumentLinkProvider = { + provideDocumentLinks(document) { + if (document.languageId !== 'isml') return []; + const text = document.getText(); + const cartridgeRoots = cartridgeService.getCartridgeRoots(); + const links: vscode.DocumentLink[] = []; + + for (const link of findTemplateLinks(text)) { + const range = new vscode.Range(document.positionAt(link.startOffset), document.positionAt(link.endOffset)); + const resolved = resolveTemplate(link.template, cartridgeRoots); + if (resolved) { + const docLink = new vscode.DocumentLink(range, vscode.Uri.file(resolved)); + docLink.tooltip = `Open ${link.template}`; + links.push(docLink); + } else if (cartridgeRoots.length > 0) { + const args = encodeURIComponent(JSON.stringify([link.template])); + const docLink = new vscode.DocumentLink( + range, + vscode.Uri.parse(`command:b2c-dx.isml.createTemplate?${args}`), + ); + docLink.tooltip = `Template "${link.template}" not found in any cartridge — click to create it`; + links.push(docLink); + } + } + + return links; + }, + }; + + context.subscriptions.push(vscode.languages.registerDocumentLinkProvider({language: 'isml'}, provider)); + + context.subscriptions.push( + vscode.commands.registerCommand('b2c-dx.isml.createTemplate', async (template: string) => { + const cartridges = cartridgeService.getCartridges(); + if (cartridges.length === 0) { + await vscode.window.showWarningMessage('No cartridges found in this workspace.'); + return; + } + + const picked = + cartridges.length === 1 + ? cartridges[0] + : await vscode.window.showQuickPick( + cartridges.map((c) => ({label: c.name, description: c.src, cartridge: c})), + {placeHolder: `Create "${template}.isml" in which cartridge?`}, + ); + if (!picked) return; + const chosen = 'cartridge' in picked ? picked.cartridge : picked; + + const trimmed = template.replace(/^\/+/, ''); + const withExt = trimmed.endsWith('.isml') ? trimmed : `${trimmed}.isml`; + const targetPath = vscode.Uri.file(`${chosen.src}/cartridge/templates/default/${withExt}`); + + try { + await vscode.workspace.fs.stat(targetPath); + // exists — just open it + } catch { + const dirUri = vscode.Uri.file(targetPath.fsPath.substring(0, targetPath.fsPath.lastIndexOf('/'))); + await vscode.workspace.fs.createDirectory(dirUri); + const stub = new TextEncoder().encode(`\n ${template}\n\n`); + await vscode.workspace.fs.writeFile(targetPath, stub); + } + const doc = await vscode.workspace.openTextDocument(targetPath); + await vscode.window.showTextDocument(doc); + }), + ); +} + +function registerHover(context: vscode.ExtensionContext): void { + const provider: vscode.HoverProvider = { + provideHover(document, position) { + if (document.languageId !== 'isml') return undefined; + const offset = document.offsetAt(position); + const info = findIsmlHoverInfo(document.getText(), offset); + if (!info) return undefined; + const tagVariant = info.isClosing ? 'Closing tag' : info.isSelfClosing ? 'Self-closing tag' : 'Opening tag'; + const parts = [ + `**<${info.tagName}>**`, + `${tagVariant}`, + '', + info.summary, + '', + '**Syntax**', + '```isml', + info.syntax, + '```', + ]; + + if (info.attributes.length > 0) { + parts.push('', `**Common attributes**: ${info.attributes.map((attr) => `\`${attr}\``).join(', ')}`); + } + + if (info.tips.length > 0) { + parts.push('', '**Tips**'); + for (const tip of info.tips) { + parts.push(`- ${tip}`); + } + } + + const contents = new vscode.MarkdownString(parts.join('\n')); + return new vscode.Hover(contents); + }, + }; + + context.subscriptions.push(vscode.languages.registerHoverProvider({language: 'isml'}, provider)); +} + +function toVscodeSeverity(severity: 'error' | 'warning'): vscode.DiagnosticSeverity { + if (severity === 'warning') return vscode.DiagnosticSeverity.Warning; + return vscode.DiagnosticSeverity.Error; +} + +function registerDiagnostics(context: vscode.ExtensionContext): void { + const collection = vscode.languages.createDiagnosticCollection('isml'); + + const update = (document: vscode.TextDocument) => { + if (document.languageId !== 'isml') { + collection.delete(document.uri); + return; + } + + const diagnostics = collectIsmlDiagnostics(document.getText()).map((entry) => { + const range = new vscode.Range(document.positionAt(entry.startOffset), document.positionAt(entry.endOffset)); + const diagnostic = new vscode.Diagnostic(range, entry.message, toVscodeSeverity(entry.severity)); + diagnostic.source = 'b2c-dx-isml'; + return diagnostic; + }); + + collection.set(document.uri, diagnostics); + }; + + for (const document of vscode.workspace.textDocuments) { + update(document); + } + + context.subscriptions.push( + collection, + vscode.workspace.onDidOpenTextDocument(update), + vscode.workspace.onDidChangeTextDocument((event) => update(event.document)), + vscode.workspace.onDidCloseTextDocument((document) => collection.delete(document.uri)), + ); +} + +function createDocumentSymbol(document: vscode.TextDocument, symbol: IsmlSymbol): vscode.DocumentSymbol { + const range = new vscode.Range(document.positionAt(symbol.startOffset), document.positionAt(symbol.endOffset)); + const selectionRange = new vscode.Range( + document.positionAt(symbol.selectionStartOffset), + document.positionAt(symbol.selectionEndOffset), + ); + const documentSymbol = new vscode.DocumentSymbol( + symbol.name, + 'ISML tag', + vscode.SymbolKind.Namespace, + range, + selectionRange, + ); + documentSymbol.children = symbol.children.map((child) => createDocumentSymbol(document, child)); + return documentSymbol; +} + +function registerDocumentSymbols(context: vscode.ExtensionContext): void { + const provider: vscode.DocumentSymbolProvider = { + provideDocumentSymbols(document) { + if (document.languageId !== 'isml') return []; + return collectIsmlSymbols(document.getText()).map((symbol) => createDocumentSymbol(document, symbol)); + }, + }; + + context.subscriptions.push(vscode.languages.registerDocumentSymbolProvider({language: 'isml'}, provider)); +} + +function registerFoldingRanges(context: vscode.ExtensionContext): void { + const provider: vscode.FoldingRangeProvider = { + provideFoldingRanges(document) { + if (document.languageId !== 'isml') return []; + + return collectIsmlFoldingRanges(document.getText()) + .map((range) => { + const start = document.positionAt(range.startOffset); + const end = document.positionAt(range.endOffset); + + if (end.line <= start.line) return undefined; + return new vscode.FoldingRange(start.line, end.line, vscode.FoldingRangeKind.Region); + }) + .filter((range): range is vscode.FoldingRange => Boolean(range)); + }, + }; + + context.subscriptions.push(vscode.languages.registerFoldingRangeProvider({language: 'isml'}, provider)); +} + +function toNameRange(document: vscode.TextDocument, startOffset: number, endOffset: number): vscode.Range { + return new vscode.Range(document.positionAt(startOffset), document.positionAt(endOffset)); +} + +function registerTagHighlights(context: vscode.ExtensionContext, cartridgeService: CartridgeService): void { + const provider: vscode.DocumentHighlightProvider = { + provideDocumentHighlights(document, position) { + if (document.languageId !== 'isml') return []; + const text = document.getText(); + const offset = document.offsetAt(position); + const match = findIsmlTagNameMatch(text, offset); + if (match) { + const tagHighlights = [ + new vscode.DocumentHighlight( + toNameRange(document, match.openingNameStartOffset, match.openingNameEndOffset), + vscode.DocumentHighlightKind.Text, + ), + new vscode.DocumentHighlight( + toNameRange(document, match.closingNameStartOffset, match.closingNameEndOffset), + vscode.DocumentHighlightKind.Text, + ), + ]; + if (tagHighlights.length > 0) return tagHighlights; + } + + const semanticRanges = findIsmlReferenceRanges( + text, + offset, + cartridgeService.getCartridgeRoots(), + document.uri.fsPath, + ); + return semanticRanges.map( + (range) => + new vscode.DocumentHighlight( + new vscode.Range(document.positionAt(range.startOffset), document.positionAt(range.endOffset)), + vscode.DocumentHighlightKind.Read, + ), + ); + }, + }; + + context.subscriptions.push(vscode.languages.registerDocumentHighlightProvider({language: 'isml'}, provider)); +} + +function registerLinkedEditing(context: vscode.ExtensionContext): void { + const provider: vscode.LinkedEditingRangeProvider = { + provideLinkedEditingRanges(document, position) { + if (document.languageId !== 'isml') return undefined; + const match = findIsmlLinkedEditingTagNameMatch(document.getText(), document.offsetAt(position)); + if (!match) return undefined; + + return new vscode.LinkedEditingRanges([ + toNameRange(document, match.openingNameStartOffset, match.openingNameEndOffset), + toNameRange(document, match.closingNameStartOffset, match.closingNameEndOffset), + ]); + }, + }; + + context.subscriptions.push(vscode.languages.registerLinkedEditingRangeProvider({language: 'isml'}, provider)); +} + +function registerDefinitions(context: vscode.ExtensionContext, cartridgeService: CartridgeService): void { + const provider: vscode.DefinitionProvider = { + provideDefinition(document, position) { + if (document.languageId !== 'isml') return undefined; + const target = findIsmlDefinitionTarget( + document.getText(), + document.offsetAt(position), + cartridgeService.getCartridgeRoots(), + document.uri.fsPath, + ); + if (!target) return undefined; + return new vscode.Location(vscode.Uri.file(target.targetPath), new vscode.Position(0, 0)); + }, + }; + + context.subscriptions.push(vscode.languages.registerDefinitionProvider({language: 'isml'}, provider)); +} + +function registerReferences(context: vscode.ExtensionContext, cartridgeService: CartridgeService): void { + const provider: vscode.ReferenceProvider = { + provideReferences(document, position) { + if (document.languageId !== 'isml') return []; + const ranges = findIsmlReferenceRanges( + document.getText(), + document.offsetAt(position), + cartridgeService.getCartridgeRoots(), + document.uri.fsPath, + ); + + return ranges.map( + (range) => + new vscode.Location( + document.uri, + new vscode.Range(document.positionAt(range.startOffset), document.positionAt(range.endOffset)), + ), + ); + }, + }; + + context.subscriptions.push(vscode.languages.registerReferenceProvider({language: 'isml'}, provider)); +} + +function registerReferencePickerCommand(context: vscode.ExtensionContext, cartridgeService: CartridgeService): void { + context.subscriptions.push( + vscode.commands.registerCommand('b2c-dx.isml.showReferences', async () => { + const editor = vscode.window.activeTextEditor; + if (!editor || editor.document.languageId !== 'isml') { + await vscode.window.showInformationMessage('Open an ISML file and place the cursor on a reference path.'); + return; + } + + const document = editor.document; + const text = document.getText(); + const offset = document.offsetAt(editor.selection.active); + const ranges = findIsmlReferenceRanges(text, offset, cartridgeService.getCartridgeRoots(), document.uri.fsPath); + + if (ranges.length === 0) { + await vscode.window.showInformationMessage('No related ISML references found at cursor.'); + return; + } + + const items = ranges.map((range) => { + const start = document.positionAt(range.startOffset); + const end = document.positionAt(range.endOffset); + const lineText = document.lineAt(start.line).text.trim(); + const isCurrent = offset >= range.startOffset && offset <= range.endOffset; + return { + label: `${isCurrent ? 'Current' : 'Reference'} • Line ${start.line + 1}`, + description: `Col ${start.character + 1}`, + detail: lineText, + range: new vscode.Range(start, end), + }; + }); + + const picked = await vscode.window.showQuickPick(items, { + placeHolder: `ISML references (${items.length})`, + matchOnDescription: true, + matchOnDetail: true, + }); + if (!picked) return; + + editor.selection = new vscode.Selection(picked.range.start, picked.range.end); + editor.revealRange(picked.range, vscode.TextEditorRevealType.InCenter); + }), + ); +} + +export function registerIsml(context: vscode.ExtensionContext, cartridgeService: CartridgeService): void { + registerAutoCloseTag(context); + registerTagCompletions(context); + registerSemanticCompletions(context, cartridgeService); + registerDocumentLinks(context, cartridgeService); + registerHover(context); + registerDiagnostics(context); + registerCodeActions(context); + registerDocumentSymbols(context); + registerFoldingRanges(context); + registerTagHighlights(context, cartridgeService); + registerLinkedEditing(context); + registerDefinitions(context, cartridgeService); + registerReferences(context, cartridgeService); + registerReferencePickerCommand(context, cartridgeService); +} diff --git a/packages/b2c-vs-extension/src/isml/matching.ts b/packages/b2c-vs-extension/src/isml/matching.ts new file mode 100644 index 00000000..68681118 --- /dev/null +++ b/packages/b2c-vs-extension/src/isml/matching.ts @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {VOID_TAGS} from './constants.js'; +import {scanIsmlTags, type IsmlTagToken} from './tags.js'; + +export interface IsmlTagNameMatch { + name: string; + openingNameStartOffset: number; + openingNameEndOffset: number; + closingNameStartOffset: number; + closingNameEndOffset: number; +} + +function isWithinName(offset: number, startOffset: number, endOffset: number): boolean { + return offset >= startOffset && offset <= endOffset; +} + +function findIsmlTagNameMatchInternal( + text: string, + offset: number, + strictTagNameMatching: boolean, +): IsmlTagNameMatch | undefined { + const stack: IsmlTagToken[] = []; + const matches: IsmlTagNameMatch[] = []; + + for (const token of scanIsmlTags(text)) { + if (!token.isClosing) { + if (!token.isSelfClosing && !VOID_TAGS.has(token.name)) { + stack.push(token); + } + continue; + } + + if (VOID_TAGS.has(token.name)) continue; + + const matchingIndex = strictTagNameMatching + ? stack.map((item) => item.name).lastIndexOf(token.name) + : stack.length - 1; + if (matchingIndex < 0) continue; + + const opening = stack[matchingIndex]; + matches.push({ + name: strictTagNameMatching ? token.name : opening.name, + openingNameStartOffset: opening.nameStartOffset, + openingNameEndOffset: opening.nameEndOffset, + closingNameStartOffset: token.nameStartOffset, + closingNameEndOffset: token.nameEndOffset, + }); + + stack.length = matchingIndex; + } + + return matches.find( + (match) => + isWithinName(offset, match.openingNameStartOffset, match.openingNameEndOffset) || + isWithinName(offset, match.closingNameStartOffset, match.closingNameEndOffset), + ); +} + +export function findIsmlTagNameMatch(text: string, offset: number): IsmlTagNameMatch | undefined { + return findIsmlTagNameMatchInternal(text, offset, true); +} + +export function findIsmlLinkedEditingTagNameMatch(text: string, offset: number): IsmlTagNameMatch | undefined { + return findIsmlTagNameMatchInternal(text, offset, false); +} diff --git a/packages/b2c-vs-extension/src/isml/scanner.ts b/packages/b2c-vs-extension/src/isml/scanner.ts new file mode 100644 index 00000000..b822cda4 --- /dev/null +++ b/packages/b2c-vs-extension/src/isml/scanner.ts @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {createRequire} from 'node:module'; + +interface Scanner { + scan: () => number; + getTokenOffset: () => number; + getTokenLength: () => number; + getTokenText: () => string; +} + +interface HtmlScannerModule { + createScanner: (input: string, initialOffset?: number, initialState?: number) => Scanner; +} + +interface HtmlLanguageTypesModule { + TokenType: { + EOS: number; + StartTag: number; + StartTagClose: number; + StartTagSelfClose: number; + AttributeName: number; + AttributeValue: number; + }; + ScannerState: { + WithinContent: number; + }; +} + +const require = createRequire(import.meta.url); +const scannerModule = require('vscode-html-languageservice/lib/umd/parser/htmlScanner.js') as HtmlScannerModule; +const typesModule = require('vscode-html-languageservice/lib/umd/htmlLanguageTypes.js') as HtmlLanguageTypesModule; + +export type IsmlScannerTokenType = + | 'startTag' + | 'startTagClose' + | 'startTagSelfClose' + | 'attributeName' + | 'attributeValue' + | 'other'; + +export interface IsmlScannerToken { + type: IsmlScannerTokenType; + offset: number; + length: number; + text: string; +} + +export function scanIsml(text: string, onToken: (token: IsmlScannerToken) => void): void { + const scanner = scannerModule.createScanner(text, 0, typesModule.ScannerState.WithinContent); + const tokenType = typesModule.TokenType; + + while (true) { + const scannedType = scanner.scan(); + if (scannedType === tokenType.EOS) return; + + let type: IsmlScannerTokenType = 'other'; + if (scannedType === tokenType.StartTag) type = 'startTag'; + else if (scannedType === tokenType.StartTagClose) type = 'startTagClose'; + else if (scannedType === tokenType.StartTagSelfClose) type = 'startTagSelfClose'; + else if (scannedType === tokenType.AttributeName) type = 'attributeName'; + else if (scannedType === tokenType.AttributeValue) type = 'attributeValue'; + + onToken({ + type, + offset: scanner.getTokenOffset(), + length: scanner.getTokenLength(), + text: scanner.getTokenText(), + }); + } +} diff --git a/packages/b2c-vs-extension/src/isml/semantic.ts b/packages/b2c-vs-extension/src/isml/semantic.ts new file mode 100644 index 00000000..fc2423ae --- /dev/null +++ b/packages/b2c-vs-extension/src/isml/semantic.ts @@ -0,0 +1,691 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import {findTemplateLinks, resolveTemplate} from './document-links.js'; + +type SemanticCompletionKind = 'resource' | 'resourceKey' | 'resourceBundle' | 'urlutils' | 'res' | 'require'; + +export interface SemanticCompletionContext { + kind: SemanticCompletionKind; + partial: string; + startOffset: number; + bundle?: string; +} + +export interface SemanticCompletionEntry { + label: string; + insertText: string; + detail: string; +} + +export interface IsmlDefinitionTarget { + startOffset: number; + endOffset: number; + targetPath: string; +} + +export interface IsmlReferenceRange { + startOffset: number; + endOffset: number; +} + +const RESOURCE_METHODS: SemanticCompletionEntry[] = [ + {label: 'msg', insertText: 'msg', detail: 'Resource.msg(key, bundle, defaultValue)'}, + {label: 'msgf', insertText: 'msgf', detail: 'Resource.msgf(key, bundle, defaultValue, ...args)'}, +]; + +const URLUTILS_METHODS: SemanticCompletionEntry[] = [ + {label: 'url', insertText: 'url', detail: 'URLUtils.url(controllerAction, ...params)'}, + {label: 'http', insertText: 'http', detail: 'URLUtils.http(controllerAction, ...params)'}, + {label: 'https', insertText: 'https', detail: 'URLUtils.https(controllerAction, ...params)'}, + {label: 'abs', insertText: 'abs', detail: 'URLUtils.abs(controllerAction, ...params)'}, +]; + +const RES_METHODS: SemanticCompletionEntry[] = [ + {label: 'render', insertText: 'render', detail: 'res.render(templatePath, model)'}, +]; + +const REQUIRE_PREFIX_METHODS: SemanticCompletionEntry[] = [ + { + label: '*/cartridge/', + insertText: '*/cartridge/', + detail: 'Require module from first matching cartridge in cartridge path order', + }, + { + label: '~/cartridge/', + insertText: '~/cartridge/', + detail: 'Require module from the current cartridge', + }, + { + label: '*/cartridge/scripts/', + insertText: '*/cartridge/scripts/', + detail: 'Require script module from first matching cartridge in cartridge path order', + }, + { + label: '~/cartridge/scripts/', + insertText: '~/cartridge/scripts/', + detail: 'Require script module from the current cartridge', + }, +]; + +export function detectSemanticCompletionContext(linePrefix: string): SemanticCompletionContext | null { + const resourceBundle = /(Resource\.msgf?\(\s*['"][^'"]*['"]\s*,\s*['"])([^'"]*)$/.exec(linePrefix); + if (resourceBundle) { + return { + kind: 'resourceBundle', + partial: resourceBundle[2], + startOffset: linePrefix.length - resourceBundle[2].length, + }; + } + + const resourceKey = /(Resource\.msgf?\(\s*['"])([^'"]*)$/.exec(linePrefix); + if (resourceKey) { + return { + kind: 'resourceKey', + partial: resourceKey[2], + startOffset: linePrefix.length - resourceKey[2].length, + }; + } + + const resource = /(Resource\.)([a-zA-Z]*)$/.exec(linePrefix); + if (resource) { + return { + kind: 'resource', + partial: resource[2], + startOffset: linePrefix.length - resource[2].length, + }; + } + + const urlutils = /(URLUtils\.)([a-zA-Z]*)$/.exec(linePrefix); + if (urlutils) { + return { + kind: 'urlutils', + partial: urlutils[2], + startOffset: linePrefix.length - urlutils[2].length, + }; + } + + const res = /(res\.)([a-zA-Z]*)$/.exec(linePrefix); + if (res) { + return { + kind: 'res', + partial: res[2], + startOffset: linePrefix.length - res[2].length, + }; + } + + const requirePath = /(require\(\s*['"])([^'"]*)$/.exec(linePrefix); + if (requirePath) { + return { + kind: 'require', + partial: requirePath[2], + startOffset: linePrefix.length - requirePath[2].length, + }; + } + + return null; +} + +interface RequireModuleCompletion { + modulePath: string; + cartridgeName: string; + cartridgeRoot: string; + cartridgeIndex: number; +} + +let cachedRequireModulesKey: string | undefined; +let cachedRequireModules: RequireModuleCompletion[] | undefined; + +function isFile(candidate: string): boolean { + try { + return fs.statSync(candidate).isFile(); + } catch { + return false; + } +} + +function normalizePathLike(value: string): string { + return value.replace(/\\/g, '/'); +} + +function findContainingCartridgeRoot(documentPath: string | undefined, cartridgeRoots: string[]): string | undefined { + if (!documentPath) return undefined; + + const containing = cartridgeRoots + .filter((root) => documentPath === root || documentPath.startsWith(`${root}${path.sep}`)) + .sort((a, b) => b.length - a.length); + return containing[0]; +} + +function collectRequireModules(cartridgeRoots: string[]): RequireModuleCompletion[] { + const cacheKey = cartridgeRoots.join('|'); + if (cachedRequireModulesKey === cacheKey && cachedRequireModules) { + return cachedRequireModules; + } + + const seen = new Set(); + const modules: RequireModuleCompletion[] = []; + + for (const [cartridgeIndex, root] of cartridgeRoots.entries()) { + const cartridgeRoot = path.join(root, 'cartridge'); + const pending: string[] = ['']; + + while (pending.length > 0) { + const relativeDir = pending.pop() ?? ''; + const absoluteDir = path.join(cartridgeRoot, relativeDir); + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(absoluteDir, {withFileTypes: true}); + } catch { + continue; + } + + for (const entry of entries) { + const relativePath = relativeDir ? path.posix.join(relativeDir, entry.name) : entry.name; + if (entry.isDirectory()) { + pending.push(relativePath); + continue; + } + + const ext = path.extname(entry.name).toLowerCase(); + if (ext !== '.js' && ext !== '.ts') continue; + + let modulePath = relativePath.replace(/\.(js|ts)$/i, ''); + if (modulePath.endsWith('/index')) { + modulePath = modulePath.slice(0, -'/index'.length); + } + if (!modulePath) continue; + + const dedupeKey = `${root}::${modulePath}`; + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + + modules.push({ + modulePath, + cartridgeName: path.basename(root), + cartridgeRoot: root, + cartridgeIndex, + }); + } + } + } + + modules.sort((a, b) => a.cartridgeIndex - b.cartridgeIndex || a.modulePath.localeCompare(b.modulePath)); + cachedRequireModulesKey = cacheKey; + cachedRequireModules = modules; + return modules; +} + +interface RankedSemanticCompletionEntry { + entry: SemanticCompletionEntry; + rank: number; + cartridgeIndex: number; +} + +function parsePropertiesKeys(filePath: string): string[] { + let content: string; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch { + return []; + } + + const keys: string[] = []; + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#') || line.startsWith('!')) continue; + const separatorIndex = line.search(/[:=]/); + const key = separatorIndex >= 0 ? line.slice(0, separatorIndex).trim() : line; + if (!key) continue; + keys.push(key); + } + return keys; +} + +function getResourceBundleCompletionEntries(partial: string, cartridgeRoots: string[]): SemanticCompletionEntry[] { + const seen = new Set(); + const entries: SemanticCompletionEntry[] = []; + + for (const root of cartridgeRoots) { + const resourceRoot = path.join(root, 'cartridge', 'templates', 'resources'); + + let files: string[]; + try { + files = fs.readdirSync(resourceRoot); + } catch { + continue; + } + + for (const fileName of files) { + if (!fileName.endsWith('.properties')) continue; + const bundleName = fileName.replace(/\.properties$/i, '').replace(/_.+$/, ''); + if (!bundleName || seen.has(bundleName)) continue; + seen.add(bundleName); + if (!bundleName.startsWith(partial)) continue; + entries.push({ + label: bundleName, + insertText: bundleName, + detail: `Resource bundle (${path.basename(root)})`, + }); + } + } + + return entries.sort((a, b) => a.label.localeCompare(b.label)); +} + +function getResourceKeyCompletionEntries( + partial: string, + cartridgeRoots: string[], + bundle?: string, +): SemanticCompletionEntry[] { + const keysByBundle = new Map>(); + + for (const root of cartridgeRoots) { + const resourceRoot = path.join(root, 'cartridge', 'templates', 'resources'); + + let files: string[]; + try { + files = fs.readdirSync(resourceRoot); + } catch { + continue; + } + + for (const fileName of files) { + if (!fileName.endsWith('.properties')) continue; + const canonicalBundleName = fileName.replace(/\.properties$/i, '').replace(/_.+$/, ''); + if (bundle && canonicalBundleName !== bundle) continue; + + const filePath = path.join(resourceRoot, fileName); + for (const key of parsePropertiesKeys(filePath)) { + if (!key.startsWith(partial)) continue; + if (!keysByBundle.has(canonicalBundleName)) keysByBundle.set(canonicalBundleName, new Set()); + keysByBundle.get(canonicalBundleName)?.add(key); + } + } + } + + const entries: SemanticCompletionEntry[] = []; + for (const [bundleName, keys] of [...keysByBundle.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + for (const key of [...keys].sort()) { + entries.push({ + label: key, + insertText: key, + detail: `Resource key (${bundleName})`, + }); + } + } + + return entries; +} + +function getRequireCompletionEntries( + partial: string, + cartridgeRoots: string[], + documentPath: string | undefined, +): SemanticCompletionEntry[] { + const normalizedPartial = normalizePathLike(partial); + const currentRoot = findContainingCartridgeRoot(documentPath, cartridgeRoots); + const cartridgePathMatch = /(?:^|\/)cartridge\/(.*)$/.exec(normalizedPartial); + const cartridgePathPartial = (cartridgePathMatch?.[1] ?? '').toLowerCase(); + const includeNonScriptsModules = + cartridgePathPartial.length > 0 && + !'scripts/'.startsWith(cartridgePathPartial) && + !'scripts'.startsWith(cartridgePathPartial); + const entries = new Map(); + + const upsertEntry = (candidate: RankedSemanticCompletionEntry): void => { + const existing = entries.get(candidate.entry.label); + if (!existing) { + entries.set(candidate.entry.label, candidate); + return; + } + + if (candidate.rank < existing.rank) { + entries.set(candidate.entry.label, candidate); + return; + } + + if (candidate.rank === existing.rank && candidate.cartridgeIndex < existing.cartridgeIndex) { + entries.set(candidate.entry.label, candidate); + } + }; + + for (const entry of REQUIRE_PREFIX_METHODS) { + if (entry.label.startsWith(normalizedPartial)) { + upsertEntry({entry, rank: entry.label.startsWith('~/') ? 0 : 1, cartridgeIndex: Number.MAX_SAFE_INTEGER}); + } + } + + for (const module of collectRequireModules(cartridgeRoots)) { + const isScriptsModule = module.modulePath.startsWith('scripts/'); + if (!isScriptsModule && !includeNonScriptsModules) continue; + + const labels: SemanticCompletionEntry[] = [ + { + label: `*/cartridge/${module.modulePath}`, + insertText: `*/cartridge/${module.modulePath}`, + detail: `Require module from cartridge path (${module.cartridgeName})`, + }, + { + label: `${module.cartridgeName}/cartridge/${module.modulePath}`, + insertText: `${module.cartridgeName}/cartridge/${module.modulePath}`, + detail: `Require module from cartridge ${module.cartridgeName}`, + }, + ]; + + if (currentRoot && module.cartridgeRoot === currentRoot) { + labels.push({ + label: `~/cartridge/${module.modulePath}`, + insertText: `~/cartridge/${module.modulePath}`, + detail: 'Require module from current cartridge', + }); + } + + for (const candidate of labels) { + if (candidate.label.startsWith(normalizedPartial)) { + const rank = candidate.label.startsWith('~/') ? 0 : candidate.label.startsWith('*/') ? 1 : 2; + upsertEntry({entry: candidate, rank, cartridgeIndex: module.cartridgeIndex}); + } + } + } + + return [...entries.values()] + .sort( + (a, b) => a.rank - b.rank || a.cartridgeIndex - b.cartridgeIndex || a.entry.label.localeCompare(b.entry.label), + ) + .map((item) => item.entry); +} + +export function getSemanticCompletionEntries( + context: SemanticCompletionContext, + cartridgeRoots: string[] = [], + documentPath?: string, +): SemanticCompletionEntry[] { + if (context.kind === 'resourceKey') { + return getResourceKeyCompletionEntries(context.partial, cartridgeRoots, context.bundle); + } + if (context.kind === 'resourceBundle') { + return getResourceBundleCompletionEntries(context.partial, cartridgeRoots); + } + if (context.kind === 'resource') { + return RESOURCE_METHODS.filter((entry) => entry.label.startsWith(context.partial)); + } + if (context.kind === 'urlutils') { + return URLUTILS_METHODS.filter((entry) => entry.label.startsWith(context.partial)); + } + if (context.kind === 'res') { + return RES_METHODS.filter((entry) => entry.label.startsWith(context.partial)); + } + return getRequireCompletionEntries(context.partial, cartridgeRoots, documentPath); +} + +function resolveResourceBundle(bundle: string, cartridgeRoots: string[]): string | undefined { + for (const root of cartridgeRoots) { + const resourceRoot = path.join(root, 'cartridge', 'templates', 'resources'); + const canonical = path.join(resourceRoot, `${bundle}.properties`); + try { + if (fs.statSync(canonical).isFile()) return canonical; + } catch { + // continue + } + + try { + const candidates = fs + .readdirSync(resourceRoot) + .filter( + (name) => name === `${bundle}.properties` || (name.startsWith(`${bundle}_`) && name.endsWith('.properties')), + ) + .sort(); + if (candidates.length > 0) { + return path.join(resourceRoot, candidates[0]); + } + } catch { + // continue + } + } + + return undefined; +} + +function collectResolvedSemanticTargets( + text: string, + cartridgeRoots: string[], + documentPath?: string, +): IsmlDefinitionTarget[] { + const targets: IsmlDefinitionTarget[] = []; + + for (const link of findTemplateLinks(text)) { + const targetPath = resolveTemplate(link.template, cartridgeRoots); + if (!targetPath) continue; + targets.push({startOffset: link.startOffset, endOffset: link.endOffset, targetPath}); + } + + const resRenderRe = /res\.render\(\s*(['"])([^'"]+)\1/g; + for (const match of text.matchAll(resRenderRe)) { + const full = match[0]; + const template = match[2]; + const matchStart = match.index ?? 0; + const templateStart = matchStart + full.indexOf(template); + const templateEnd = templateStart + template.length; + const targetPath = resolveTemplate(template, cartridgeRoots); + if (!targetPath) continue; + targets.push({startOffset: templateStart, endOffset: templateEnd, targetPath}); + } + + const requireCallRe = /require\(\s*(['"])([^'"]+)\1\s*\)/g; + for (const match of text.matchAll(requireCallRe)) { + const full = match[0]; + const modulePath = match[2]; + const matchStart = match.index ?? 0; + const moduleStart = matchStart + full.indexOf(modulePath); + const moduleEnd = moduleStart + modulePath.length; + const targetPath = resolveRequireModule(modulePath, cartridgeRoots, documentPath); + if (!targetPath) continue; + targets.push({startOffset: moduleStart, endOffset: moduleEnd, targetPath}); + } + + return targets; +} + +export function findIsmlReferenceRanges( + text: string, + offset: number, + cartridgeRoots: string[], + documentPath?: string, +): IsmlReferenceRange[] { + const current = findIsmlDefinitionTarget(text, offset, cartridgeRoots, documentPath); + if (!current) return []; + + const seen = new Set(); + const ranges: IsmlReferenceRange[] = []; + for (const candidate of collectResolvedSemanticTargets(text, cartridgeRoots, documentPath)) { + if (candidate.targetPath !== current.targetPath) continue; + const key = `${candidate.startOffset}:${candidate.endOffset}`; + if (seen.has(key)) continue; + seen.add(key); + ranges.push({startOffset: candidate.startOffset, endOffset: candidate.endOffset}); + } + + ranges.sort((a, b) => a.startOffset - b.startOffset || a.endOffset - b.endOffset); + return ranges; +} + +function resolveRequireModule( + modulePath: string, + cartridgeRoots: string[], + documentPath: string | undefined, +): string | undefined { + const normalized = normalizePathLike(modulePath).trim(); + if (!normalized) return undefined; + + const resolveScriptCandidate = (basePath: string): string | undefined => { + if (basePath.endsWith('.js') || basePath.endsWith('.ts')) { + if (isFile(basePath)) return basePath; + return undefined; + } + + const candidates = [ + `${basePath}.js`, + `${basePath}.ts`, + path.join(basePath, 'index.js'), + path.join(basePath, 'index.ts'), + ]; + return candidates.find((candidate) => isFile(candidate)); + }; + + if (normalized.startsWith('~/')) { + const currentRoot = findContainingCartridgeRoot(documentPath, cartridgeRoots); + if (!currentRoot) return undefined; + return resolveScriptCandidate(path.join(currentRoot, normalized.slice(2))); + } + + if (normalized.startsWith('*/')) { + const relative = normalized.slice(2); + for (const root of cartridgeRoots) { + const resolved = resolveScriptCandidate(path.join(root, relative)); + if (resolved) return resolved; + } + return undefined; + } + + const cartridgePathMatch = /^([^/]+)\/(cartridge\/.+)$/.exec(normalized); + if (cartridgePathMatch) { + const cartridgeName = cartridgePathMatch[1]; + const relative = cartridgePathMatch[2]; + const cartridgeRoot = cartridgeRoots.find((root) => path.basename(root) === cartridgeName); + if (!cartridgeRoot) return undefined; + return resolveScriptCandidate(path.join(cartridgeRoot, relative)); + } + + return undefined; +} + +function resolveController(controllerAction: string, cartridgeRoots: string[]): string | undefined { + const controllerName = controllerAction.split('-')[0]?.trim(); + if (!controllerName) return undefined; + + for (const root of cartridgeRoots) { + const controllerRoot = path.join(root, 'cartridge', 'controllers'); + const jsCandidate = path.join(controllerRoot, `${controllerName}.js`); + try { + if (fs.statSync(jsCandidate).isFile()) return jsCandidate; + } catch { + // continue + } + + const tsCandidate = path.join(controllerRoot, `${controllerName}.ts`); + try { + if (fs.statSync(tsCandidate).isFile()) return tsCandidate; + } catch { + // continue + } + } + + return undefined; +} + +function isWithin(offset: number, startOffset: number, endOffset: number): boolean { + return offset >= startOffset && offset <= endOffset; +} + +export function findIsmlDefinitionTarget( + text: string, + offset: number, + cartridgeRoots: string[], + documentPath?: string, +): IsmlDefinitionTarget | undefined { + for (const link of findTemplateLinks(text)) { + const quoteBeforeValue = link.startOffset > 0 ? text[link.startOffset - 1] : undefined; + const quoteAfterValue = link.endOffset < text.length ? text[link.endOffset] : undefined; + const onOpeningQuote = (quoteBeforeValue === '"' || quoteBeforeValue === "'") && offset === link.startOffset - 1; + const onClosingQuote = (quoteAfterValue === '"' || quoteAfterValue === "'") && offset === link.endOffset; + + const tagStart = text.lastIndexOf('<', link.startOffset); + const templateAttrStart = text.lastIndexOf('template', link.startOffset); + const onTemplateAttributeName = + templateAttrStart >= tagStart && isWithin(offset, templateAttrStart, templateAttrStart + 'template'.length); + + if ( + !isWithin(offset, link.startOffset, link.endOffset) && + !onOpeningQuote && + !onClosingQuote && + !onTemplateAttributeName + ) { + continue; + } + + const targetPath = resolveTemplate(link.template, cartridgeRoots); + if (!targetPath) return undefined; + return {startOffset: link.startOffset, endOffset: link.endOffset, targetPath}; + } + + const resourceCallRe = /Resource\.msgf?\(\s*(['"])([^'"]+)\1\s*,\s*(['"])([^'"]+)\3/g; + for (const match of text.matchAll(resourceCallRe)) { + const full = match[0]; + const key = match[2]; + const bundle = match[4]; + const matchStart = match.index ?? 0; + + const keyStart = matchStart + full.indexOf(key); + const keyEnd = keyStart + key.length; + const bundleStart = matchStart + full.lastIndexOf(bundle); + const bundleEnd = bundleStart + bundle.length; + + if (!isWithin(offset, keyStart, keyEnd) && !isWithin(offset, bundleStart, bundleEnd)) continue; + + const targetPath = resolveResourceBundle(bundle, cartridgeRoots); + if (!targetPath) return undefined; + return {startOffset: keyStart, endOffset: keyEnd, targetPath}; + } + + const urlUtilsCallRe = /URLUtils\.(?:url|http|https|abs)\(\s*(['"])([^'"]+)\1/g; + for (const match of text.matchAll(urlUtilsCallRe)) { + const full = match[0]; + const action = match[2]; + const matchStart = match.index ?? 0; + const actionStart = matchStart + full.indexOf(action); + const actionEnd = actionStart + action.length; + if (!isWithin(offset, actionStart, actionEnd)) continue; + + const targetPath = resolveController(action, cartridgeRoots); + if (!targetPath) return undefined; + return {startOffset: actionStart, endOffset: actionEnd, targetPath}; + } + + const resRenderRe = /res\.render\(\s*(['"])([^'"]+)\1/g; + for (const match of text.matchAll(resRenderRe)) { + const full = match[0]; + const template = match[2]; + const matchStart = match.index ?? 0; + const templateStart = matchStart + full.indexOf(template); + const templateEnd = templateStart + template.length; + if (!isWithin(offset, templateStart, templateEnd)) continue; + + const targetPath = resolveTemplate(template, cartridgeRoots); + if (!targetPath) return undefined; + return {startOffset: templateStart, endOffset: templateEnd, targetPath}; + } + + const requireCallRe = /require\(\s*(['"])([^'"]+)\1\s*\)/g; + for (const match of text.matchAll(requireCallRe)) { + const full = match[0]; + const modulePath = match[2]; + const matchStart = match.index ?? 0; + const moduleStart = matchStart + full.indexOf(modulePath); + const moduleEnd = moduleStart + modulePath.length; + if (!isWithin(offset, moduleStart, moduleEnd)) continue; + + const targetPath = resolveRequireModule(modulePath, cartridgeRoots, documentPath); + if (!targetPath) return undefined; + return {startOffset: moduleStart, endOffset: moduleEnd, targetPath}; + } + + return undefined; +} diff --git a/packages/b2c-vs-extension/src/isml/snippets.ts b/packages/b2c-vs-extension/src/isml/snippets.ts new file mode 100644 index 00000000..e6f77bcc --- /dev/null +++ b/packages/b2c-vs-extension/src/isml/snippets.ts @@ -0,0 +1,284 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +export interface IsmlTagSnippet { + prefix: string; + body: string[]; + description: string; +} + +export const TAG_SNIPPETS: IsmlTagSnippet[] = [ + { + prefix: 'isif', + body: ['', '\t$0', ''], + description: 'ISML conditional block', + }, + { + prefix: 'isifelse', + body: ['', '\t$2', '', '\t$0', ''], + description: 'ISML / conditional block', + }, + { + prefix: 'isifelseif', + body: [ + '', + '\t$2', + '', + '\t$4', + '', + '\t$0', + '', + ], + description: 'ISML // conditional block', + }, + {prefix: 'iselse', body: [''], description: 'ISML branch'}, + { + prefix: 'iselseif', + body: [''], + description: 'ISML branch', + }, + { + prefix: 'isloop', + body: ['', '\t$0', ''], + description: 'ISML iteration', + }, + { + prefix: 'isloopstatus', + body: ['', '\t$0', ''], + description: 'ISML with loop status object', + }, + { + prefix: 'isinclude', + body: [''], + description: 'Include another ISML template', + }, + { + prefix: 'isincludeurl', + body: [''], + description: 'Include a remote URL', + }, + { + prefix: 'isincludeurllocale', + body: [""], + description: 'Remote include with explicit locale', + }, + { + prefix: 'isset', + body: [''], + description: 'Set a variable in ISML', + }, + { + prefix: 'isscript', + body: ['', '\t$0', ''], + description: 'ISML server-side script block', + }, + { + prefix: 'isscriptrequire', + body: ['', "\tvar ${1:module} = require('${2:*/cartridge/scripts/module}');", '\t$0', ''], + description: 'ISML with a require() call', + }, + { + prefix: 'isscriptassets', + body: [ + '', + "\tvar assets = require('*/cartridge/scripts/assets');", + "\tassets.addCss('${1:/css/global.css}');", + "\tassets.addJs('${2:/js/main.js}');", + '', + ], + description: ' registering CSS/JS assets via assets module', + }, + { + prefix: 'isdecorate', + body: ['', '\t$0', ''], + description: 'Decorate template with a layout', + }, + { + prefix: 'iscontent', + body: [ + '', + ], + description: 'Set the response content type', + }, + { + prefix: 'iscomment', + body: ['', '\t$0', ''], + description: 'ISML comment block (not rendered)', + }, + { + prefix: 'ismodule', + body: [''], + description: 'Define a custom ISML module', + }, + { + prefix: 'isprint', + body: [ + '', + ], + description: 'Render a value with explicit encoding', + }, + { + prefix: 'isprintformatter', + body: [''], + description: 'Render a value with a number/date formatter', + }, + { + prefix: 'isredirect', + body: [''], + description: 'Redirect the response to another URL', + }, + { + prefix: 'iscache', + body: [''], + description: 'Configure page caching', + }, + { + prefix: 'isobject', + body: ['', '\t$0', ''], + description: 'Wrap markup so personalization tracking can pick up the object', + }, + { + prefix: 'isslot', + body: [ + '', + ], + description: 'Render a content slot', + }, + { + prefix: 'isstatus', + body: [''], + description: 'Set the HTTP response status', + }, + {prefix: 'isbreak', body: [''], description: 'Break out of the enclosing '}, + {prefix: 'iscontinue', body: [''], description: 'Skip to next iteration of the enclosing '}, + {prefix: 'isnext', body: [''], description: 'Move to next iteration of the enclosing '}, + { + prefix: 'iscustomtag', + body: [''], + description: 'Invoke a custom ISML module tag', + }, + { + prefix: 'isactivedatacontext', + body: [''], + description: 'Set Active Data context for tracking', + }, + { + prefix: 'isanalyticsoff', + body: [''], + description: 'Suppress Active Data tracking for the surrounding block', + }, + { + prefix: 'iscomponent', + body: [''], + description: 'Render a Page Designer component (legacy )', + }, + { + prefix: 'isregion', + body: [ + '', + "\tvar PageMgr = require('dw/experience/PageMgr');", + '', + '', + ], + description: 'Render a Page Designer region inside a page template', + }, + { + prefix: 'isincludeoptional', + body: [''], + description: 'Include without storefront toolkit injection (no Active Data scripts)', + }, + { + prefix: 'isloopiter', + body: ['', '\t$0', ''], + description: ' using iterator/alias (alternative form to items/var)', + }, + { + prefix: 'isobjectview', + body: [ + '', + '\t$0', + '', + ], + description: ' with the most common view values', + }, + { + prefix: 'isprintformatdate', + body: [''], + description: 'Format a date with explicit timezone', + }, + { + prefix: 'isprintformatcurrency', + body: [''], + description: 'Format a currency value', + }, + { + prefix: 'iscacheoff', + body: [''], + description: 'Disable caching for this template', + }, + { + prefix: 'iscachevarycustomer', + body: [ + '', + ], + description: 'Cache with price/promotion variation (per-customer-segment)', + }, + { + prefix: 'isslotglobal', + body: [''], + description: 'Render a global content slot', + }, + { + prefix: 'isslotcategory', + body: [ + '', + ], + description: 'Render a category-context content slot', + }, + { + prefix: 'isslotfolder', + body: [ + '', + ], + description: 'Render a folder-context content slot', + }, + { + prefix: 'isscriptpdict', + body: ['', '\tvar ${1:variable} = pdict.${2:property};', '\t$0', ''], + description: ' reading from pdict', + }, + { + prefix: 'isscripttransaction', + body: [ + '', + "\tvar Transaction = require('dw/system/Transaction');", + '\tTransaction.wrap(function () {', + '\t\t$0', + '\t});', + '', + ], + description: ' with a Transaction.wrap() block', + }, +]; + +export interface CompletionContext { + partial: string; + hasLeadingBracket: boolean; + startOffset: number; +} + +const PARTIAL_TAG_RE = / entry.symbol.name).lastIndexOf(token.name); + if (matchingIndex < 0) continue; + + for (let i = stack.length - 1; i >= matchingIndex; i--) { + const current = stack[i]; + current.symbol.endOffset = token.endOffset; + current.symbol.children = current.children; + stack.pop(); + + if (stack.length > 0) { + stack[stack.length - 1].children.push(current.symbol); + } else { + roots.push(current.symbol); + } + } + continue; + } + + const symbol: IsmlSymbol = { + name: token.name, + startOffset: token.startOffset, + endOffset: token.endOffset, + selectionStartOffset: token.nameStartOffset, + selectionEndOffset: token.nameEndOffset, + children: [], + }; + + if (token.isSelfClosing || VOID_TAGS.has(token.name)) { + if (stack.length > 0) { + stack[stack.length - 1].children.push(symbol); + } else { + roots.push(symbol); + } + continue; + } + + stack.push({symbol, children: []}); + } + + while (stack.length > 0) { + const current = stack.pop(); + if (!current) break; + current.symbol.children = current.children; + current.symbol.endOffset = text.length; + if (stack.length > 0) { + stack[stack.length - 1].children.push(current.symbol); + } else { + roots.push(current.symbol); + } + } + + return roots; +} diff --git a/packages/b2c-vs-extension/src/isml/tags.ts b/packages/b2c-vs-extension/src/isml/tags.ts new file mode 100644 index 00000000..ef76e746 --- /dev/null +++ b/packages/b2c-vs-extension/src/isml/tags.ts @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +export interface IsmlTagToken { + name: string; + isClosing: boolean; + isSelfClosing: boolean; + startOffset: number; + endOffset: number; + nameStartOffset: number; + nameEndOffset: number; +} + +function isNameStartChar(ch: string): boolean { + return /[a-zA-Z]/.test(ch); +} + +function isNameChar(ch: string): boolean { + return /[\w-]/.test(ch); +} + +export function scanIsmlTags(text: string): IsmlTagToken[] { + const tokens: IsmlTagToken[] = []; + let i = 0; + + while (i < text.length) { + const start = text.indexOf('<', i); + if (start < 0) break; + + if (text.startsWith('', start + 4); + i = endComment >= 0 ? endComment + 3 : text.length; + continue; + } + + let cursor = start + 1; + let isClosing = false; + + while (cursor < text.length && /\s/.test(text[cursor])) cursor++; + if (text[cursor] === '/') { + isClosing = true; + cursor++; + while (cursor < text.length && /\s/.test(text[cursor])) cursor++; + } + + if (!isNameStartChar(text[cursor] ?? '')) { + i = start + 1; + continue; + } + + const nameStart = cursor; + cursor++; + while (cursor < text.length && isNameChar(text[cursor])) cursor++; + const rawName = text.slice(nameStart, cursor); + if (!rawName.toLowerCase().startsWith('is')) { + i = start + 1; + continue; + } + + let quote: '"' | "'" | null = null; + let end = -1; + for (let j = cursor; j < text.length; j++) { + const ch = text[j]; + if (quote) { + if (ch === quote) quote = null; + } else if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === '>') { + end = j; + break; + } + } + + if (end < 0) break; + + const inner = text.slice(cursor, end); + const isSelfClosing = !isClosing && /\/\s*$/.test(inner); + + tokens.push({ + name: rawName.toLowerCase(), + isClosing, + isSelfClosing, + startOffset: start, + endOffset: end + 1, + nameStartOffset: nameStart, + nameEndOffset: cursor, + }); + + i = end + 1; + } + + return tokens; +} diff --git a/packages/b2c-vs-extension/src/test/isml.test.ts b/packages/b2c-vs-extension/src/test/isml.test.ts new file mode 100644 index 00000000..c79b27f8 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/isml.test.ts @@ -0,0 +1,747 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import * as assert from 'assert'; + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import {collectIsmlDiagnostics, getIsmlQuickFixes} from '../isml/diagnostics.js'; +import {findTemplateLinks, resolveTemplate} from '../isml/document-links.js'; +import {collectIsmlFoldingRanges} from '../isml/folding.js'; +import {findIsmlHoverInfo} from '../isml/hover.js'; +import {detectAutoCloseTag} from '../isml/index.js'; +import {findIsmlLinkedEditingTagNameMatch, findIsmlTagNameMatch} from '../isml/matching.js'; +import { + detectSemanticCompletionContext, + findIsmlDefinitionTarget, + findIsmlReferenceRanges, + getSemanticCompletionEntries, +} from '../isml/semantic.js'; +import {detectCompletionContext} from '../isml/snippets.js'; +import {collectIsmlSymbols} from '../isml/symbols.js'; + +suite('ISML: detectAutoCloseTag', () => { + test('auto-closes ', () => { + const result = detectAutoCloseTag(''); + assert.deepStrictEqual(result, {tagName: 'isif'}); + }); + + test('auto-closes ', () => { + const result = detectAutoCloseTag(' '); + assert.deepStrictEqual(result, {tagName: 'isloop'}); + }); + + test('auto-closes ', () => { + assert.deepStrictEqual(detectAutoCloseTag(''), {tagName: 'isscript'}); + }); + + test('auto-closes ', () => { + assert.deepStrictEqual(detectAutoCloseTag(''), { + tagName: 'isdecorate', + }); + }); + + test('auto-closes ', () => { + assert.deepStrictEqual(detectAutoCloseTag(''), { + tagName: 'isobject', + }); + }); + + test('auto-closes ', () => { + assert.deepStrictEqual(detectAutoCloseTag(''), {tagName: 'iscomment'}); + }); + + test('does not auto-close self-closing ', () => { + assert.strictEqual(detectAutoCloseTag(''), null); + }); + + test('does not auto-close known void tag ', () => { + assert.strictEqual(detectAutoCloseTag(''), null); + }); + + test('does not auto-close ', () => { + assert.strictEqual(detectAutoCloseTag(''), null); + }); + + test('does not auto-close ', () => { + assert.strictEqual(detectAutoCloseTag(''), null); + }); + + test('does not fire when typing > inside text content', () => { + assert.strictEqual(detectAutoCloseTag('Hello world>'), null); + }); + + test('does not fire on closing tag', () => { + assert.strictEqual(detectAutoCloseTag(''), null); + }); + + test('does not fire if line does not end with >', () => { + assert.strictEqual(detectAutoCloseTag('', () => { + assert.strictEqual(detectAutoCloseTag(''), null); + }); + + test('handles attribute with > in expression by NOT being matched', () => { + // Ensure parser logic is resilient when '>' appears inside quoted attribute content. + const result = detectAutoCloseTag(''); + assert.deepStrictEqual(result, {tagName: 'isif'}); + }); + + test('does not double-close when angle bracket appears in attribute string', () => { + // After typing the closing > of , only one should be added. + const result = detectAutoCloseTag(''); + assert.deepStrictEqual(result, {tagName: 'isif'}); + }); +}); + +suite('ISML: folding', () => { + test('collects folding ranges for nested block tags', () => { + const text = [ + '', + ' ', + ' ', + ' ', + '', + ].join('\n'); + + const ranges = collectIsmlFoldingRanges(text); + assert.strictEqual(ranges.length, 2); + + const folded = ranges.map((range) => text.slice(range.startOffset, range.endOffset)); + assert.ok(folded.some((value) => value.includes(' value.includes(' { + const text = ['', '', ''].join('\n'); + const ranges = collectIsmlFoldingRanges(text); + assert.deepStrictEqual(ranges, []); + }); +}); + +suite('ISML: semantic completions', () => { + test('detects Resource completion context', () => { + const ctx = detectSemanticCompletionContext(' ${Resource.m'); + assert.ok(ctx); + assert.strictEqual(ctx?.kind, 'resource'); + assert.strictEqual(ctx?.partial, 'm'); + }); + + test('detects URLUtils completion context', () => { + const ctx = detectSemanticCompletionContext('URLUtils.ht'); + assert.ok(ctx); + assert.strictEqual(ctx?.kind, 'urlutils'); + assert.strictEqual(ctx?.partial, 'ht'); + }); + + test('returns semantic completion entries filtered by partial', () => { + const entries = getSemanticCompletionEntries({kind: 'urlutils', partial: 'ht', startOffset: 0}); + assert.strictEqual(entries.length, 2); + assert.strictEqual(entries[0].label, 'http'); + assert.strictEqual(entries[1].label, 'https'); + }); + + test('detects require completion context', () => { + const ctx = detectSemanticCompletionContext("const helper = require('*/cartridge/scripts/ut"); + assert.ok(ctx); + assert.strictEqual(ctx?.kind, 'require'); + assert.strictEqual(ctx?.partial, '*/cartridge/scripts/ut'); + }); + + test('detects Resource.msg key completion context', () => { + const ctx = detectSemanticCompletionContext("${Resource.msg('wel"); + assert.ok(ctx); + assert.strictEqual(ctx?.kind, 'resourceKey'); + assert.strictEqual(ctx?.partial, 'wel'); + }); + + test('returns resource key completion entries from properties files', () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'isml-semantic-resource-')); + const cart = path.join(tmpRoot, 'app_a'); + fs.mkdirSync(path.join(cart, 'cartridge', 'templates', 'resources'), {recursive: true}); + fs.writeFileSync( + path.join(cart, 'cartridge', 'templates', 'resources', 'messages.properties'), + ['welcome=Hello', 'welcome_fmt=Hello {0}', 'other=Other'].join('\n'), + ); + + try { + const entries = getSemanticCompletionEntries( + {kind: 'resourceKey', partial: 'wel', startOffset: 0}, + [cart], + path.join(cart, 'cartridge', 'templates', 'default', 'home', 'index.isml'), + ); + + const labels = entries.map((entry) => entry.label); + assert.deepStrictEqual(labels, ['welcome', 'welcome_fmt']); + } finally { + fs.rmSync(tmpRoot, {recursive: true, force: true}); + } + }); +}); + +suite('ISML: semantic definitions', () => { + let tmpRoot: string; + let cartA: string; + let cartB: string; + + setup(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'isml-semantic-')); + cartA = path.join(tmpRoot, 'app_a'); + cartB = path.join(tmpRoot, 'app_b'); + + fs.mkdirSync(path.join(cartA, 'cartridge', 'templates', 'default', 'common'), {recursive: true}); + fs.mkdirSync(path.join(cartA, 'cartridge', 'templates', 'resources'), {recursive: true}); + fs.mkdirSync(path.join(cartA, 'cartridge', 'controllers'), {recursive: true}); + fs.mkdirSync(path.join(cartA, 'cartridge', 'models'), {recursive: true}); + fs.mkdirSync(path.join(cartA, 'cartridge', 'scripts', 'util'), {recursive: true}); + fs.mkdirSync(path.join(cartB, 'cartridge', 'scripts', 'util'), {recursive: true}); + fs.mkdirSync(path.join(cartB, 'cartridge', 'scripts', 'shared'), {recursive: true}); + + fs.writeFileSync(path.join(cartA, 'cartridge', 'templates', 'default', 'common', 'header.isml'), '
'); + fs.writeFileSync(path.join(cartA, 'cartridge', 'templates', 'resources', 'messages.properties'), 'hello=Hello'); + fs.writeFileSync(path.join(cartA, 'cartridge', 'controllers', 'Home.js'), 'module.exports = {};'); + fs.writeFileSync(path.join(cartA, 'cartridge', 'models', 'Product.ts'), 'export {};'); + fs.writeFileSync(path.join(cartA, 'cartridge', 'scripts', 'util', 'foo.js'), 'module.exports = {};'); + fs.writeFileSync(path.join(cartB, 'cartridge', 'scripts', 'util', 'foo.ts'), 'export {};'); + fs.writeFileSync(path.join(cartB, 'cartridge', 'scripts', 'shared', 'index.ts'), 'export {};'); + }); + + teardown(() => { + fs.rmSync(tmpRoot, {recursive: true, force: true}); + }); + + test('resolves template attribute definition target', () => { + const text = ''; + const offset = text.indexOf('common/header') + 2; + const target = findIsmlDefinitionTarget(text, offset, [cartA]); + assert.ok(target); + assert.strictEqual( + target?.targetPath, + path.join(cartA, 'cartridge', 'templates', 'default', 'common', 'header.isml'), + ); + }); + + test('resolves template attribute definition target from template attribute name', () => { + const text = ''; + const offset = text.indexOf('template') + 2; + const target = findIsmlDefinitionTarget(text, offset, [cartA]); + assert.ok(target); + assert.strictEqual( + target?.targetPath, + path.join(cartA, 'cartridge', 'templates', 'default', 'common', 'header.isml'), + ); + }); + + test('resolves template attribute definition target from template value quote', () => { + const text = ''; + const offset = text.indexOf('"common/header"'); + const target = findIsmlDefinitionTarget(text, offset, [cartA]); + assert.ok(target); + assert.strictEqual( + target?.targetPath, + path.join(cartA, 'cartridge', 'templates', 'default', 'common', 'header.isml'), + ); + }); + + test('resolves Resource.msg bundle definition target', () => { + const text = "${Resource.msg('welcome', 'messages', null)}"; + const offset = text.indexOf('messages') + 2; + const target = findIsmlDefinitionTarget(text, offset, [cartA]); + assert.ok(target); + assert.strictEqual( + target?.targetPath, + path.join(cartA, 'cartridge', 'templates', 'resources', 'messages.properties'), + ); + }); + + test('resolves URLUtils controller definition target', () => { + const text = "${URLUtils.url('Home-Show')}"; + const offset = text.indexOf('Home-Show') + 2; + const target = findIsmlDefinitionTarget(text, offset, [cartA]); + assert.ok(target); + assert.strictEqual(target?.targetPath, path.join(cartA, 'cartridge', 'controllers', 'Home.js')); + }); + + test('resolves res.render template definition target', () => { + const text = "\nres.render('common/header', {});\n"; + const offset = text.indexOf('common/header') + 2; + const target = findIsmlDefinitionTarget(text, offset, [cartA]); + assert.ok(target); + assert.strictEqual( + target?.targetPath, + path.join(cartA, 'cartridge', 'templates', 'default', 'common', 'header.isml'), + ); + }); + + test('returns require completion entries for current cartridge scripts', () => { + const entries = getSemanticCompletionEntries( + {kind: 'require', partial: '~/cartridge/scripts/u', startOffset: 0}, + [cartA, cartB], + path.join(cartA, 'cartridge', 'templates', 'default', 'common', 'header.isml'), + ); + + const labels = entries.map((entry) => entry.label); + assert.ok(labels.includes('~/cartridge/scripts/util/foo')); + }); + + test('keeps default require suggestions scripts-focused', () => { + const entries = getSemanticCompletionEntries( + {kind: 'require', partial: '*/cartridge/', startOffset: 0}, + [cartA, cartB], + path.join(cartA, 'cartridge', 'templates', 'default', 'common', 'header.isml'), + ); + + const labels = entries.map((entry) => entry.label); + assert.ok(labels.includes('*/cartridge/scripts/util/foo')); + assert.ok(!labels.includes('*/cartridge/controllers/Home')); + assert.ok(!labels.includes('*/cartridge/models/Product')); + }); + + test('returns require completion entries for non-scripts cartridge modules', () => { + const entries = getSemanticCompletionEntries( + {kind: 'require', partial: '*/cartridge/controllers/H', startOffset: 0}, + [cartA, cartB], + path.join(cartA, 'cartridge', 'templates', 'default', 'common', 'header.isml'), + ); + + const labels = entries.map((entry) => entry.label); + assert.ok(labels.includes('*/cartridge/controllers/Home')); + }); + + test('dedupes wildcard require completion by cartridge path order', () => { + const entries = getSemanticCompletionEntries( + {kind: 'require', partial: '*/cartridge/scripts/util/foo', startOffset: 0}, + [cartA, cartB], + path.join(cartA, 'cartridge', 'templates', 'default', 'common', 'header.isml'), + ); + + const wildcardMatches = entries.filter((entry) => entry.label === '*/cartridge/scripts/util/foo'); + assert.strictEqual(wildcardMatches.length, 1); + assert.ok(wildcardMatches[0].detail.includes('(app_a)')); + }); + + test('resolves require wildcard definition target', () => { + const text = "\nvar util = require('*/cartridge/scripts/util/foo');\n"; + const offset = text.indexOf('util/foo') + 2; + const target = findIsmlDefinitionTarget(text, offset, [cartA, cartB]); + assert.ok(target); + assert.strictEqual(target?.targetPath, path.join(cartA, 'cartridge', 'scripts', 'util', 'foo.js')); + }); + + test('resolves require current cartridge definition target', () => { + const text = "\nvar util = require('~/cartridge/scripts/util/foo');\n"; + const offset = text.indexOf('util/foo') + 2; + const target = findIsmlDefinitionTarget( + text, + offset, + [cartA, cartB], + path.join(cartA, 'cartridge', 'templates', 'default', 'common', 'header.isml'), + ); + assert.ok(target); + assert.strictEqual(target?.targetPath, path.join(cartA, 'cartridge', 'scripts', 'util', 'foo.js')); + }); + + test('resolves require explicit cartridge definition target', () => { + const text = "\nvar shared = require('app_b/cartridge/scripts/shared');\n"; + const offset = text.indexOf('app_b') + 2; + const target = findIsmlDefinitionTarget(text, offset, [cartA, cartB]); + assert.ok(target); + assert.strictEqual(target?.targetPath, path.join(cartB, 'cartridge', 'scripts', 'shared', 'index.ts')); + }); + + test('finds references for template links and res.render to same template target', () => { + const text = [ + '', + '', + "res.render('common/header', {});", + '', + ].join('\n'); + + const offset = text.indexOf('common/header') + 2; + const ranges = findIsmlReferenceRanges( + text, + offset, + [cartA], + path.join(cartA, 'cartridge', 'templates', 'default'), + ); + assert.strictEqual(ranges.length, 2); + + const values = ranges.map((range) => text.slice(range.startOffset, range.endOffset)); + assert.deepStrictEqual(values, ['common/header', 'common/header']); + }); + + test('finds references for require calls resolving to same target module', () => { + const text = [ + '', + "var a = require('*/cartridge/scripts/util/foo');", + "var b = require('app_a/cartridge/scripts/util/foo');", + '', + ].join('\n'); + + const offset = text.indexOf('*/cartridge/scripts/util/foo') + 2; + const ranges = findIsmlReferenceRanges( + text, + offset, + [cartA, cartB], + path.join(cartA, 'cartridge', 'templates', 'default', 'common', 'header.isml'), + ); + + assert.strictEqual(ranges.length, 2); + const values = ranges.map((range) => text.slice(range.startOffset, range.endOffset)); + assert.deepStrictEqual(values, ['*/cartridge/scripts/util/foo', 'app_a/cartridge/scripts/util/foo']); + }); +}); + +suite('ISML: tag matching', () => { + test('finds matching pair for opening tag name', () => { + const text = ''; + const offset = text.indexOf('isloop') + 2; + const match = findIsmlTagNameMatch(text, offset); + assert.ok(match); + assert.strictEqual(match?.name, 'isloop'); + + const opening = text.slice(match?.openingNameStartOffset ?? 0, match?.openingNameEndOffset ?? 0); + const closing = text.slice(match?.closingNameStartOffset ?? 0, match?.closingNameEndOffset ?? 0); + assert.strictEqual(opening, 'isloop'); + assert.strictEqual(closing, 'isloop'); + }); + + test('finds matching pair for closing tag name', () => { + const text = ''; + const offset = text.lastIndexOf('isif') + 2; + const match = findIsmlTagNameMatch(text, offset); + assert.ok(match); + assert.strictEqual(match?.name, 'isif'); + }); + + test('returns undefined for self-closing tags', () => { + const text = ''; + const offset = text.indexOf('isinclude') + 2; + assert.strictEqual(findIsmlTagNameMatch(text, offset), undefined); + }); + + test('returns undefined when offset is outside tag names', () => { + const text = ''; + const offset = text.indexOf('condition'); + assert.strictEqual(findIsmlTagNameMatch(text, offset), undefined); + }); + + test('linked editing match tolerates temporary mismatched names', () => { + const text = ''; + const offset = text.indexOf('isforeach') + 2; + + assert.strictEqual(findIsmlTagNameMatch(text, offset), undefined); + + const linkedMatch = findIsmlLinkedEditingTagNameMatch(text, offset); + assert.ok(linkedMatch); + + const opening = text.slice(linkedMatch?.openingNameStartOffset ?? 0, linkedMatch?.openingNameEndOffset ?? 0); + const closing = text.slice(linkedMatch?.closingNameStartOffset ?? 0, linkedMatch?.closingNameEndOffset ?? 0); + assert.strictEqual(opening, 'isforeach'); + assert.strictEqual(closing, 'isif'); + }); +}); + +suite('ISML: detectCompletionContext', () => { + test('triggers on bare prefix `is`', () => { + const ctx = detectCompletionContext('is'); + assert.ok(ctx); + assert.strictEqual(ctx?.partial, 'is'); + assert.strictEqual(ctx?.hasLeadingBracket, false); + assert.strictEqual(ctx?.startOffset, 0); + }); + + test('triggers on `isif`', () => { + const ctx = detectCompletionContext('isif'); + assert.ok(ctx); + assert.strictEqual(ctx?.partial, 'isif'); + assert.strictEqual(ctx?.hasLeadingBracket, false); + }); + + test('triggers on ` { + const ctx = detectCompletionContext(' { + const ctx = detectCompletionContext(' { + assert.strictEqual(detectCompletionContext('hello'), null); + }); + + test('does not trigger after a space', () => { + assert.strictEqual(detectCompletionContext('', () => { + assert.strictEqual(detectCompletionContext(''), null); + }); + + test('captures partial name following <', () => { + const ctx = detectCompletionContext(' { + test('finds isinclude template attribute', () => { + const text = ''; + const links = findTemplateLinks(text); + assert.strictEqual(links.length, 1); + assert.strictEqual(links[0].template, 'common/header'); + assert.strictEqual(text.slice(links[0].startOffset, links[0].endOffset), 'common/header'); + }); + + test('finds isdecorate template attribute', () => { + const links = findTemplateLinks(''); + assert.strictEqual(links.length, 1); + assert.strictEqual(links[0].template, 'common/layout/page'); + }); + + test('finds ismodule template attribute', () => { + const links = findTemplateLinks(''); + assert.strictEqual(links.length, 1); + assert.strictEqual(links[0].template, 'path/to/mod'); + }); + + test('handles single quotes', () => { + const links = findTemplateLinks(""); + assert.strictEqual(links.length, 1); + assert.strictEqual(links[0].template, 'cart/miniCart'); + }); + + test('finds multiple template attributes', () => { + const text = ''; + const links = findTemplateLinks(text); + assert.strictEqual(links.length, 2); + assert.strictEqual(links[0].template, 'a'); + assert.strictEqual(links[1].template, 'b'); + }); + + test('ignores url attribute on isinclude', () => { + const links = findTemplateLinks(''); + assert.strictEqual(links.length, 0); + }); + + test('ignores template attribute on unknown tags', () => { + const links = findTemplateLinks('
'); + assert.strictEqual(links.length, 0); + }); + + test('returns empty for empty input', () => { + assert.deepStrictEqual(findTemplateLinks(''), []); + }); + + test('supports multiline attributes inside template tags', () => { + const text = ''; + const links = findTemplateLinks(text); + assert.strictEqual(links.length, 1); + assert.strictEqual(links[0].template, 'common/header'); + }); + + test('ignores variable template attribute values', () => { + const text = ''; + const links = findTemplateLinks(text); + assert.strictEqual(links.length, 0); + }); + + test('supports unquoted template attribute values', () => { + const text = ''; + const links = findTemplateLinks(text); + assert.strictEqual(links.length, 1); + assert.strictEqual(links[0].template, 'common/header'); + }); +}); + +suite('ISML: resolveTemplate', () => { + let tmpRoot: string; + let cartA: string; + let cartB: string; + + setup(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'isml-resolve-')); + cartA = path.join(tmpRoot, 'app_a'); + cartB = path.join(tmpRoot, 'app_b'); + fs.mkdirSync(path.join(cartA, 'cartridge', 'templates', 'default', 'common'), {recursive: true}); + fs.mkdirSync(path.join(cartB, 'cartridge', 'templates', 'default', 'common'), {recursive: true}); + fs.mkdirSync(path.join(cartB, 'cartridge', 'templates', 'fr_FR', 'product'), {recursive: true}); + fs.writeFileSync(path.join(cartA, 'cartridge', 'templates', 'default', 'common', 'header.isml'), '
'); + fs.writeFileSync(path.join(cartB, 'cartridge', 'templates', 'default', 'common', 'header.isml'), ''); + fs.writeFileSync(path.join(cartB, 'cartridge', 'templates', 'fr_FR', 'product', 'detail.isml'), ''); + }); + + teardown(() => { + fs.rmSync(tmpRoot, {recursive: true, force: true}); + }); + + test('resolves to first cartridge in path order', () => { + const resolved = resolveTemplate('common/header', [cartA, cartB]); + assert.strictEqual(resolved, path.join(cartA, 'cartridge', 'templates', 'default', 'common', 'header.isml')); + }); + + test('falls through to next cartridge if first does not have it', () => { + const resolved = resolveTemplate('product/detail', [cartA, cartB]); + assert.ok(resolved && resolved.includes('app_b')); + }); + + test('falls through to non-default locale when needed', () => { + fs.rmSync(path.join(cartB, 'cartridge', 'templates', 'default'), {recursive: true}); + const resolved = resolveTemplate('product/detail', [cartA, cartB]); + assert.ok(resolved && resolved.includes('fr_FR')); + }); + + test('handles already-suffixed paths', () => { + const resolved = resolveTemplate('common/header.isml', [cartA]); + assert.strictEqual(resolved, path.join(cartA, 'cartridge', 'templates', 'default', 'common', 'header.isml')); + }); + + test('handles leading slash', () => { + const resolved = resolveTemplate('/common/header', [cartA]); + assert.ok(resolved); + }); + + test('returns undefined when not found anywhere', () => { + const resolved = resolveTemplate('does/not/exist', [cartA, cartB]); + assert.strictEqual(resolved, undefined); + }); + + test('returns undefined for empty cartridge list', () => { + assert.strictEqual(resolveTemplate('common/header', []), undefined); + }); +}); + +suite('ISML: diagnostics', () => { + test('returns empty diagnostics for valid markup', () => { + const diagnostics = collectIsmlDiagnostics(''); + assert.deepStrictEqual(diagnostics, []); + }); + + test('reports unexpected closing tag', () => { + const diagnostics = collectIsmlDiagnostics(''); + assert.strictEqual(diagnostics.length, 1); + assert.strictEqual(diagnostics[0].message, 'Unexpected closing tag .'); + }); + + test('reports unclosed opening tag', () => { + const diagnostics = collectIsmlDiagnostics(''); + assert.strictEqual(diagnostics.length, 1); + assert.strictEqual(diagnostics[0].message, 'Tag is not closed.'); + }); + + test('reports mismatched closing tag with expected closing tag', () => { + const diagnostics = collectIsmlDiagnostics(''); + assert.ok(diagnostics.some((d) => d.message === 'Mismatched closing tag . Expected .')); + }); + + test('reports non-self-closing void tags as warning', () => { + const diagnostics = collectIsmlDiagnostics(''); + assert.strictEqual(diagnostics.length, 1); + assert.strictEqual(diagnostics[0].severity, 'warning'); + assert.strictEqual(diagnostics[0].message, 'ISML void tag should be self-closing.'); + }); +}); + +suite('ISML: diagnostic quick fixes', () => { + test('returns self-closing quick fix for non-self-closing void tag', () => { + const text = ''; + const diagnostic = collectIsmlDiagnostics(text)[0]; + const fixes = getIsmlQuickFixes(text, diagnostic); + + assert.strictEqual(fixes.length, 1); + assert.strictEqual(fixes[0].title, 'Make self-closing'); + assert.strictEqual(fixes[0].edits.length, 1); + assert.strictEqual(fixes[0].edits[0].newText, ''); + }); + + test('returns replacement quick fix for invalid void closing tag', () => { + const text = ''; + const diagnostic = collectIsmlDiagnostics(text)[0]; + const fixes = getIsmlQuickFixes(text, diagnostic); + + assert.strictEqual(fixes.length, 1); + assert.strictEqual(fixes[0].title, 'Replace with '); + assert.strictEqual(fixes[0].edits.length, 1); + assert.strictEqual(fixes[0].edits[0].newText, ''); + }); +}); + +suite('ISML: hover', () => { + test('returns hover info for known ISML tag', () => { + const text = ''; + const offset = text.indexOf('isinclude') + 2; + const info = findIsmlHoverInfo(text, offset); + assert.ok(info); + assert.strictEqual(info?.tagName, 'isinclude'); + assert.ok(info?.summary.includes('Includes another template')); + assert.strictEqual(info?.syntax, ''); + assert.ok(info?.attributes.includes('template')); + assert.strictEqual(info?.isClosing, false); + assert.strictEqual(info?.isSelfClosing, true); + }); + + test('returns hover info for isslot tag', () => { + const text = ''; + const offset = text.indexOf('isslot') + 2; + const info = findIsmlHoverInfo(text, offset); + assert.ok(info); + assert.strictEqual(info?.tagName, 'isslot'); + assert.strictEqual(info?.isSelfClosing, true); + assert.ok(info?.attributes.includes('id')); + }); + + test('returns hover info for closing tags with closing context', () => { + const text = ''; + const offset = text.lastIndexOf('isif') + 2; + const info = findIsmlHoverInfo(text, offset); + assert.ok(info); + assert.strictEqual(info?.tagName, 'isif'); + assert.strictEqual(info?.isClosing, true); + assert.strictEqual(info?.isSelfClosing, false); + }); + + test('returns undefined for unknown tag names', () => { + const text = ''; + const offset = text.indexOf('isunknown') + 2; + const info = findIsmlHoverInfo(text, offset); + assert.strictEqual(info, undefined); + }); +}); + +suite('ISML: symbols', () => { + test('collects nested symbols', () => { + const text = ''; + const symbols = collectIsmlSymbols(text); + assert.strictEqual(symbols.length, 1); + assert.strictEqual(symbols[0].name, 'isif'); + assert.strictEqual(symbols[0].children.length, 1); + assert.strictEqual(symbols[0].children[0].name, 'isloop'); + assert.strictEqual(symbols[0].children[0].children.length, 1); + assert.strictEqual(symbols[0].children[0].children[0].name, 'isprint'); + }); + + test('keeps self-closing tags at current nesting level', () => { + const text = ''; + const symbols = collectIsmlSymbols(text); + assert.strictEqual(symbols.length, 1); + assert.strictEqual(symbols[0].children.length, 1); + assert.strictEqual(symbols[0].children[0].name, 'isprint'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f087f210..ee305bc4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ catalogs: typescript-eslint: specifier: ^8 version: 8.54.0 + vscode-html-languageservice: + specifier: 5.6.0 + version: 5.6.0 overrides: baseline-browser-mapping: '>=2.9.19' @@ -599,6 +602,9 @@ importers: react-dom: specifier: 18.3.1 version: 18.3.1(react@18.3.1) + vscode-html-languageservice: + specifier: 'catalog:' + version: 5.6.0 devDependencies: '@eslint/compat': specifier: 'catalog:' @@ -3639,6 +3645,9 @@ packages: '@vscode/debugprotocol@1.68.0': resolution: {integrity: sha512-2J27dysaXmvnfuhFGhfeuxfHRXunqNPxtBoR3koiTOA9rdxWNDTa1zIFLCFMSHJ9MPTPKFcBeblsyaCJCIlQxg==} + '@vscode/l10n@0.0.18': + resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==} + '@vscode/test-cli@0.0.12': resolution: {integrity: sha512-iYN0fDg29+a2Xelle/Y56Xvv7Nc8Thzq4VwpzAF/SIE6918rDicqfsQxV6w1ttr2+SOm+10laGuY9FG2ptEKsQ==} engines: {node: '>=18'} @@ -7858,6 +7867,18 @@ packages: postcss: optional: true + vscode-html-languageservice@5.6.0: + resolution: {integrity: sha512-FIVz83oGw2tBkOr8gQPeiREInnineCKGCz3ZD1Pi6opOuX3nSRkc4y4zLLWsuop+6ttYX//XZCI6SLzGhRzLmA==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + vue@3.5.32: resolution: {integrity: sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==} peerDependencies: @@ -11639,6 +11660,8 @@ snapshots: '@vscode/debugprotocol@1.68.0': {} + '@vscode/l10n@0.0.18': {} + '@vscode/test-cli@0.0.12': dependencies: '@types/mocha': 10.0.10 @@ -16435,6 +16458,19 @@ snapshots: - universal-cookie - yaml + vscode-html-languageservice@5.6.0: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.1.0 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-uri@3.1.0: {} + vue@3.5.32(typescript@5.9.3): dependencies: '@vue/compiler-dom': 3.5.32 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ae721a5d..dc6cdb07 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,6 +10,7 @@ catalog: glob: 13.0.0 jsonschema: 1.5.0 open: 11.0.0 + vscode-html-languageservice: 5.6.0 # Dev dependencies (shared across packages) '@eslint/compat': ^1