diff --git a/acumate-plugin/src/providers/html-completion-provider.ts b/acumate-plugin/src/providers/html-completion-provider.ts
index 8d72c6e..6c8dcf2 100644
--- a/acumate-plugin/src/providers/html-completion-provider.ts
+++ b/acumate-plugin/src/providers/html-completion-provider.ts
@@ -4,13 +4,10 @@ import {
getRelatedTsFiles,
loadClassInfosFromFiles,
CollectedClassInfo,
- createClassInfoLookup,
resolveViewBinding,
ClassPropertyInfo,
- filterScreenLikeClasses,
collectActionProperties,
extractConfigPropertyNames,
- filterClassesBySource,
} from '../utils';
import {
parseDocumentDom,
@@ -18,6 +15,7 @@ import {
elevateToElementNode,
getAttributeContext,
findParentViewName,
+ isActionStateBindTag,
} from './html-shared';
import {
ClientControlMetadata,
@@ -27,6 +25,16 @@ import { getScreenTemplates } from '../services/screen-template-service';
import { getIncludeMetadata, IncludeMetadata } from '../services/include-service';
import { BackendFieldMetadata, normalizeMetaName } from '../backend-metadata-utils';
import { loadBackendFieldsForView } from './html-backend-utils';
+import { getBaseScreenDocument } from '../services/screen-html-service';
+import {
+ HtmlIncludeFieldContext,
+ createHtmlFieldMetadataContext,
+ getFieldPropertiesFromViews,
+ getIncludeFieldContext,
+ getParentOrSelectorViewName,
+ getViewNameFromCustomizationSelectors,
+ resolveTemplateValue,
+} from '../services/html-field-context-service';
// Registers completions so HTML view bindings stay in sync with PX metadata.
export function registerHtmlCompletionProvider(context: vscode.ExtensionContext) {
@@ -103,13 +111,8 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider {
return undefined;
}
- const relevantClassInfos = filterClassesBySource(classInfos, tsFilePaths);
- if (!relevantClassInfos.length) {
- return undefined;
- }
-
- const classInfoLookup = createClassInfoLookup(classInfos);
- const screenClasses = filterScreenLikeClasses(relevantClassInfos);
+ const metadataContext = createHtmlFieldMetadataContext(classInfos, tsFilePaths);
+ const { classInfoLookup, screenClasses } = metadataContext;
// Completions are sourced from the same metadata as validation/definitions to keep behavior consistent.
if (attributeContext.attributeName === 'view.bind') {
@@ -121,7 +124,9 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider {
}
if (attributeContext.attributeName === 'state.bind') {
- return this.createActionCompletions(screenClasses);
+ return isActionStateBindTag(attributeContext.tagName)
+ ? this.createActionCompletions(screenClasses, classInfoLookup, elementNode, attributeContext.value)
+ : this.createControlStateCompletions(attributeContext.value, screenClasses, classInfoLookup);
}
if (attributeContext.attributeName === 'control-state.bind' && attributeContext.tagName === 'qp-field') {
@@ -130,7 +135,35 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider {
if (attributeContext.attributeName === 'name' && attributeContext.tagName === 'field') {
// Field completions are scoped to the PXView resolved from the surrounding markup.
- const viewName = findParentViewName(elementNode);
+ const includeContext = getIncludeFieldContext({
+ documentPath: document.uri.fsPath,
+ elementNode,
+ hostTsFilePaths: tsFilePaths,
+ workspaceRoots: vscode.workspace.workspaceFolders?.map(folder => folder.uri.fsPath),
+ });
+ if (includeContext) {
+ const hostSelectorViewName = resolveTemplateValue(
+ getViewNameFromCustomizationSelectors(elementNode, includeContext.templateDocument),
+ includeContext.parameterValues
+ );
+ const hostSelectorResolution = hostSelectorViewName
+ ? resolveViewBinding(hostSelectorViewName, screenClasses, classInfoLookup)
+ : undefined;
+ if (hostSelectorResolution?.viewClass) {
+ const backendFields = await loadBackendFieldsForView(hostSelectorViewName!, screenClasses);
+ return this.createFieldCompletions(hostSelectorResolution.viewClass.properties, backendFields);
+ }
+
+ const items = this.createIncludeFieldNameCompletions(elementNode, includeContext);
+ if (items?.length) {
+ return items;
+ }
+ }
+
+ const viewName = getParentOrSelectorViewName(
+ elementNode,
+ getBaseScreenDocument(document.uri.fsPath)
+ );
if (!viewName) {
return undefined;
}
@@ -366,6 +399,22 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider {
return items.length ? items : undefined;
}
+ private createIncludeFieldNameCompletions(
+ elementNode: any,
+ context: HtmlIncludeFieldContext
+ ): vscode.CompletionItem[] | undefined {
+ const selectorViewName = getViewNameFromCustomizationSelectors(elementNode, context.templateDocument);
+ const viewName = resolveTemplateValue(selectorViewName, context.parameterValues);
+ if (viewName) {
+ const resolution = resolveViewBinding(viewName, context.screenClasses, context.classInfoLookup);
+ const viewClass = resolution?.viewClass;
+ return viewClass ? this.createFieldCompletions(viewClass.properties) : undefined;
+ }
+
+ const fields = getFieldPropertiesFromViews(context);
+ return fields.size ? this.createFieldCompletions(fields) : undefined;
+ }
+
private isInsideDataFeed(node: any): boolean {
let current = node?.parent ?? node?.parentNode;
while (current) {
@@ -590,14 +639,58 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider {
return items;
}
- private createActionCompletions(screenClasses: CollectedClassInfo[]): vscode.CompletionItem[] {
+ private createActionCompletions(
+ screenClasses: CollectedClassInfo[],
+ classInfoLookup: Map,
+ elementNode: any,
+ currentValue?: string
+ ): vscode.CompletionItem[] {
+ const normalizedPrefix = (currentValue ?? '').trim().toLowerCase();
const actionMap = collectActionProperties(screenClasses);
const items: vscode.CompletionItem[] = [];
- actionMap.forEach((property, name) => {
+ const seen = new Set();
+ const addAction = (name: string, property: ClassPropertyInfo) => {
+ if (seen.has(name)) {
+ return;
+ }
+ if (normalizedPrefix && !name.toLowerCase().startsWith(normalizedPrefix)) {
+ return;
+ }
+ seen.add(name);
const item = new vscode.CompletionItem(name, vscode.CompletionItemKind.Function);
item.detail = property.typeName ?? 'PXActionState';
items.push(item);
+ };
+
+ actionMap.forEach((property, name) => {
+ addAction(name, property);
});
+
+ const viewName = findParentViewName(elementNode);
+ const viewClass = viewName
+ ? resolveViewBinding(viewName, screenClasses, classInfoLookup)?.viewClass
+ : undefined;
+ viewClass?.properties.forEach((property, name) => {
+ if (property.kind === 'action') {
+ addAction(name, property);
+ }
+ });
+
+ for (const screenClass of screenClasses) {
+ for (const [propertyName, property] of screenClass.properties) {
+ if (property.kind !== 'view' && property.kind !== 'viewCollection') {
+ continue;
+ }
+
+ const viewClass = resolveViewBinding(propertyName, screenClasses, classInfoLookup)?.viewClass;
+ viewClass?.properties.forEach((viewProperty, actionName) => {
+ if (viewProperty.kind === 'action') {
+ addAction(`${propertyName}.${actionName}`, viewProperty);
+ }
+ });
+ }
+ }
+
return items;
}
diff --git a/acumate-plugin/src/providers/html-definition-provider.ts b/acumate-plugin/src/providers/html-definition-provider.ts
index 7543de6..9dfe9b8 100644
--- a/acumate-plugin/src/providers/html-definition-provider.ts
+++ b/acumate-plugin/src/providers/html-definition-provider.ts
@@ -6,11 +6,8 @@ import {
loadClassInfosFromFiles,
CollectedClassInfo,
ClassPropertyInfo,
- createClassInfoLookup,
resolveViewBinding,
- filterScreenLikeClasses,
collectActionProperties,
- filterClassesBySource,
getLineAndColumnFromIndex,
} from '../utils';
import {
@@ -19,6 +16,7 @@ import {
elevateToElementNode,
getAttributeContext,
findParentViewName,
+ isActionStateBindTag,
} from './html-shared';
import { resolveIncludeFilePath } from '../services/include-service';
import {
@@ -26,8 +24,20 @@ import {
isCustomizationSelectorAttribute,
queryBaseScreenElements,
BaseScreenDocument,
- getCustomizationSelectorAttributes,
+ getDocumentForNode,
} from '../services/screen-html-service';
+import {
+ HtmlFieldMetadataContext,
+ HtmlIncludeFieldContext,
+ createHtmlFieldMetadataContext,
+ findFieldsInAnyView,
+ getIncludeFieldContext,
+ parseFieldReference,
+ resolveHtmlField,
+} from '../services/html-field-context-service';
+
+type DefinitionMetadataContext = HtmlFieldMetadataContext;
+type IncludeDefinitionContext = HtmlIncludeFieldContext;
// Hooks VS Code so view/field bindings support "Go to Definition" directly from HTML.
export function registerHtmlDefinitionProvider(context: vscode.ExtensionContext) {
@@ -64,9 +74,16 @@ export class HtmlDefinitionProvider implements vscode.DefinitionProvider {
}
const baseScreenDocument = getBaseScreenDocument(document.uri.fsPath);
+ const workspaceRoots = vscode.workspace.workspaceFolders?.map(folder => folder.uri.fsPath);
+ const hostTsFilePaths = getRelatedTsFiles(document.uri.fsPath);
+ const includeContext = getIncludeFieldContext({
+ documentPath: document.uri.fsPath,
+ elementNode,
+ hostTsFilePaths,
+ workspaceRoots,
+ });
if (attributeContext.attributeName === 'url' && attributeContext.tagName === 'qp-include') {
- const workspaceRoots = vscode.workspace.workspaceFolders?.map(folder => folder.uri.fsPath);
const includePath = resolveIncludeFilePath(attributeContext.value, document.uri.fsPath, workspaceRoots);
if (!includePath) {
return;
@@ -75,40 +92,45 @@ export class HtmlDefinitionProvider implements vscode.DefinitionProvider {
}
if (isCustomizationSelectorAttribute(attributeContext.attributeName)) {
- if (!baseScreenDocument) {
- return;
- }
-
const selector = attributeContext.value?.trim();
if (!selector) {
return;
}
- const { nodes, error } = queryBaseScreenElements(baseScreenDocument, selector);
- if (error || !nodes.length) {
- return;
+ const includeLocations = getSelectorLocations(includeContext?.templateDocument, selector);
+ if (includeLocations.length) {
+ return includeLocations;
}
- const locations: vscode.Location[] = [];
- const seen = new Set();
- for (const nodeCandidate of nodes) {
- const startIndex = typeof nodeCandidate.startIndex === 'number' ? nodeCandidate.startIndex : undefined;
- if (startIndex === undefined || seen.has(startIndex)) {
- continue;
- }
- seen.add(startIndex);
- const location = createLocationFromHtmlNode(baseScreenDocument, nodeCandidate);
- if (location) {
- locations.push(location);
- }
+ const baseLocations = getSelectorLocations(baseScreenDocument, selector);
+ if (baseLocations.length) {
+ return baseLocations;
}
+ }
- if (locations.length) {
- return locations;
+ if (
+ attributeContext.attributeName === 'name' &&
+ attributeContext.tagName === 'field' &&
+ hasUnboundAttribute(elementNode)
+ ) {
+ return;
+ }
+
+ if (attributeContext.attributeName === 'name' && attributeContext.tagName === 'field' && includeContext) {
+ const includeLocations = getFieldDefinitionLocations(
+ attributeContext.value,
+ elementNode,
+ includeContext,
+ includeContext.templateDocument,
+ includeContext.parameterValues,
+ true
+ );
+ if (includeLocations.length) {
+ return includeLocations;
}
}
- const tsFilePaths = getRelatedTsFiles(document.uri.fsPath);
+ const tsFilePaths = hostTsFilePaths;
if (!tsFilePaths.length) {
return;
}
@@ -118,13 +140,8 @@ export class HtmlDefinitionProvider implements vscode.DefinitionProvider {
return;
}
- const relevantClassInfos = filterClassesBySource(classInfos, tsFilePaths);
- if (!relevantClassInfos.length) {
- return;
- }
-
- const classInfoLookup = createClassInfoLookup(classInfos);
- const screenClasses = filterScreenLikeClasses(relevantClassInfos);
+ const documentMetadataContext = createHtmlFieldMetadataContext(classInfos, tsFilePaths);
+ const { classInfoLookup, screenClasses } = documentMetadataContext;
// Resolved metadata lets us jump from HTML bindings directly to the backing TypeScript symbol.
if (attributeContext.attributeName === 'view.bind' || (attributeContext.attributeName === 'view' && attributeContext.tagName === 'using')) {
@@ -162,11 +179,29 @@ export class HtmlDefinitionProvider implements vscode.DefinitionProvider {
}
if (attributeContext.attributeName === 'state.bind') {
- const actionProperty = findActionProperty(attributeContext.value, screenClasses);
- if (!actionProperty) {
- return;
+ if (isActionStateBindTag(attributeContext.tagName)) {
+ const actionProperty =
+ findQualifiedViewActionProperty(attributeContext.value, documentMetadataContext) ??
+ findActionProperty(attributeContext.value, screenClasses) ??
+ findViewActionProperty(attributeContext.value, elementNode, documentMetadataContext);
+ if (!actionProperty) {
+ return;
+ }
+ return createLocationFromProperty(actionProperty);
+ }
+
+ const locations = getFieldDefinitionLocations(
+ attributeContext.value,
+ elementNode,
+ documentMetadataContext,
+ includeContext?.templateDocument ?? baseScreenDocument,
+ includeContext?.parameterValues,
+ false,
+ false
+ );
+ if (locations.length) {
+ return locations;
}
- return createLocationFromProperty(actionProperty);
}
if (attributeContext.attributeName === 'control-state.bind' && attributeContext.tagName === 'qp-field') {
@@ -184,30 +219,97 @@ export class HtmlDefinitionProvider implements vscode.DefinitionProvider {
if (attributeContext.attributeName === 'name' && attributeContext.tagName === 'field') {
// Field names dereference through the closest parent view to locate the property in TS.
- let viewName = findParentViewName(elementNode);
- if (!viewName) {
- viewName = getViewNameFromCustomizationSelectors(attributeContext.node, baseScreenDocument);
- }
- if (!viewName) {
- return;
+ const locations = getFieldDefinitionLocations(
+ attributeContext.value,
+ elementNode,
+ documentMetadataContext,
+ includeContext?.templateDocument ?? baseScreenDocument,
+ includeContext?.parameterValues
+ );
+ if (locations.length) {
+ return locations;
}
+ }
- const resolution = resolveViewBinding(viewName, screenClasses, classInfoLookup);
- const viewClass = resolution?.viewClass;
- if (!viewClass) {
- return;
- }
+ return undefined;
+ }
+}
- const fieldProperty = viewClass.properties.get(attributeContext.value);
- if (!fieldProperty || fieldProperty.kind !== 'field') {
- return;
- }
+function hasUnboundAttribute(elementNode: any): boolean {
+ return Boolean(elementNode?.attribs && Object.prototype.hasOwnProperty.call(elementNode.attribs, 'unbound'));
+}
- return createLocationFromProperty(fieldProperty);
+function getSelectorLocations(
+ document: BaseScreenDocument | undefined,
+ selector: string
+): vscode.Location[] {
+ if (!document) {
+ return [];
+ }
+
+ const { nodes, matches, error } = queryBaseScreenElements(document, selector);
+ if (error || !nodes.length) {
+ return [];
+ }
+
+ const locations: vscode.Location[] = [];
+ const seen = new Set();
+ const selectorMatches = matches.length
+ ? matches
+ : nodes.map(node => ({ node, document: getDocumentForNode(document, node) }));
+ for (const { node: nodeCandidate, document: sourceDocument } of selectorMatches) {
+ const startIndex = typeof nodeCandidate.startIndex === 'number' ? nodeCandidate.startIndex : undefined;
+ const key = startIndex === undefined ? undefined : `${sourceDocument.filePath}:${startIndex}`;
+ if (key === undefined || seen.has(key)) {
+ continue;
+ }
+ seen.add(key);
+ const location = createLocationFromHtmlNode(sourceDocument, nodeCandidate);
+ if (location) {
+ locations.push(location);
}
+ }
- return undefined;
+ return locations;
+}
+
+function getFieldDefinitionLocations(
+ rawFieldName: string | undefined,
+ elementNode: any,
+ metadataContext: DefinitionMetadataContext,
+ selectorDocument?: BaseScreenDocument,
+ parameterValues?: Map,
+ allowAnyViewFallback = false,
+ useParentView = true
+): vscode.Location[] {
+ if (!rawFieldName) {
+ return [];
+ }
+
+ const parsed = parseFieldReference(rawFieldName);
+ const resolution = resolveHtmlField({
+ fieldReference: parsed,
+ elementNode,
+ metadataContext,
+ selectorDocument,
+ parameterValues,
+ allowAnyViewFallback,
+ useParentView,
+ });
+ if (!resolution || resolution.hasTemplatedBinding) {
+ return [];
+ }
+
+ if (resolution.fieldProperty?.kind === 'field' && !resolution.usedAnyViewFallback) {
+ return [createLocationFromProperty(resolution.fieldProperty)];
+ }
+
+ if (!allowAnyViewFallback) {
+ return [];
}
+
+ return findFieldsInAnyView(resolution.fieldName, metadataContext)
+ .map(match => createLocationFromProperty(match.fieldProperty));
}
function findActionProperty(actionName: string | undefined, screenClasses: CollectedClassInfo[]): ClassPropertyInfo | undefined {
@@ -218,6 +320,60 @@ function findActionProperty(actionName: string | undefined, screenClasses: Colle
return actions.get(actionName);
}
+function findViewActionProperty(
+ actionName: string | undefined,
+ elementNode: any,
+ metadataContext: DefinitionMetadataContext
+): ClassPropertyInfo | undefined {
+ if (!actionName) {
+ return undefined;
+ }
+
+ const viewName = findParentViewName(elementNode);
+ const viewClass = viewName
+ ? resolveViewBinding(viewName, metadataContext.screenClasses, metadataContext.classInfoLookup)?.viewClass
+ : undefined;
+ const actionProperty = viewClass?.properties.get(actionName);
+ return actionProperty?.kind === 'action' ? actionProperty : undefined;
+}
+
+function findQualifiedViewActionProperty(
+ actionBinding: string | undefined,
+ metadataContext: DefinitionMetadataContext
+): ClassPropertyInfo | undefined {
+ const parsed = parseQualifiedActionBinding(actionBinding);
+ if (!parsed) {
+ return undefined;
+ }
+
+ const viewClass = resolveViewBinding(
+ parsed.viewName,
+ metadataContext.screenClasses,
+ metadataContext.classInfoLookup
+ )?.viewClass;
+ const actionProperty = viewClass?.properties.get(parsed.actionName);
+ return actionProperty?.kind === 'action' ? actionProperty : undefined;
+}
+
+function parseQualifiedActionBinding(value: string | undefined): { viewName: string; actionName: string } | undefined {
+ if (!value) {
+ return undefined;
+ }
+
+ const parts = value.split('.');
+ if (parts.length !== 2) {
+ return undefined;
+ }
+
+ const viewName = parts[0]?.trim();
+ const actionName = parts[1]?.trim();
+ if (!viewName || !actionName) {
+ return undefined;
+ }
+
+ return { viewName, actionName };
+}
+
function parseControlStateBinding(value: string | undefined): { viewName: string; fieldName: string } | undefined {
if (!value) {
return undefined;
@@ -250,45 +406,6 @@ function createLocationFromHtmlNode(document: BaseScreenDocument, node: any): vs
);
}
-function getViewNameFromCustomizationSelectors(
- node: any,
- baseDocument: BaseScreenDocument | undefined
-): string | undefined {
- if (!baseDocument?.dom?.length) {
- return undefined;
- }
-
- const attributes = node?.attribs;
- if (!attributes) {
- return undefined;
- }
-
- for (const attributeName of getCustomizationSelectorAttributes()) {
- const rawValue = attributes[attributeName];
- if (typeof rawValue !== 'string') {
- continue;
- }
- const normalizedValue = rawValue.trim();
- if (!normalizedValue.length) {
- continue;
- }
-
- const { nodes, error } = queryBaseScreenElements(baseDocument, normalizedValue);
- if (error || !nodes.length) {
- continue;
- }
-
- for (const candidate of nodes) {
- const viewName = findParentViewName(candidate);
- if (viewName) {
- return viewName;
- }
- }
- }
-
- return undefined;
-}
-
// Converts a collected property back into a VS Code location for navigation.
function createLocationFromProperty(property: ClassPropertyInfo): vscode.Location {
return createLocationFromTsNode(property.sourceFile, property.node);
diff --git a/acumate-plugin/src/providers/html-hover-provider.ts b/acumate-plugin/src/providers/html-hover-provider.ts
index a5293ef..8cac7f4 100644
--- a/acumate-plugin/src/providers/html-hover-provider.ts
+++ b/acumate-plugin/src/providers/html-hover-provider.ts
@@ -4,19 +4,19 @@ import {
findNodeAtOffset,
elevateToElementNode,
getAttributeContext,
- findParentViewName,
HtmlAttributeContext,
+ isActionStateBindTag,
} from './html-shared';
-import {
- getRelatedTsFiles,
- loadClassInfosFromFiles,
- filterClassesBySource,
- createClassInfoLookup,
- filterScreenLikeClasses,
- resolveViewBinding,
-} from '../utils';
+import { getRelatedTsFiles } from '../utils';
import { loadBackendFieldsForView } from './html-backend-utils';
import { BackendFieldMetadata, normalizeMetaName } from '../backend-metadata-utils';
+import { getBaseScreenDocument } from '../services/screen-html-service';
+import {
+ getIncludeFieldContext,
+ loadHtmlFieldMetadataContext,
+ parseFieldReference,
+ resolveHtmlField,
+} from '../services/html-field-context-service';
export function registerHtmlHoverProvider(context: vscode.ExtensionContext) {
const provider = vscode.languages.registerHoverProvider(
@@ -53,11 +53,13 @@ export async function provideHtmlFieldHover(
}
const attributeContext = getAttributeContext(document, offset, elementNode);
- if (!attributeContext || !isFieldNameAttribute(attributeContext)) {
+ if (!attributeContext || !isFieldHoverAttribute(attributeContext)) {
return undefined;
}
- return buildFieldHover(document.uri.fsPath, attributeContext, elementNode);
+ return buildFieldHover(document.uri.fsPath, attributeContext, elementNode, {
+ useParentView: isFieldNameAttribute(attributeContext),
+ });
}
function isFieldNameAttribute(attribute: HtmlAttributeContext): boolean {
@@ -69,10 +71,19 @@ function isFieldNameAttribute(attribute: HtmlAttributeContext): boolean {
return tagName === 'field' || tagName === 'qp-field';
}
+function isFieldHoverAttribute(attribute: HtmlAttributeContext): boolean {
+ if (isFieldNameAttribute(attribute)) {
+ return true;
+ }
+
+ return attribute.attributeName === 'state.bind' && !isActionStateBindTag(attribute.tagName);
+}
+
async function buildFieldHover(
htmlFilePath: string,
attributeContext: HtmlAttributeContext,
- elementNode: any
+ elementNode: any,
+ options: { useParentView: boolean }
): Promise {
const fieldName = attributeContext.value?.trim();
if (!fieldName) {
@@ -80,42 +91,69 @@ async function buildFieldHover(
}
const tsFilePaths = getRelatedTsFiles(htmlFilePath);
- if (!tsFilePaths.length) {
- return undefined;
- }
-
- const classInfos = loadClassInfosFromFiles(tsFilePaths);
- if (!classInfos.length) {
- return undefined;
- }
-
- const relevantClassInfos = filterClassesBySource(classInfos, tsFilePaths);
- if (!relevantClassInfos.length) {
- return undefined;
- }
-
- const screenClasses = filterScreenLikeClasses(relevantClassInfos);
- if (!screenClasses.length) {
- return undefined;
- }
-
- const viewName = findParentViewName(elementNode);
- if (!viewName) {
- return undefined;
- }
-
- const classInfoLookup = createClassInfoLookup(classInfos);
- const resolution = resolveViewBinding(viewName, screenClasses, classInfoLookup);
- if (!resolution) {
- return undefined;
- }
-
- const backendFields = await loadBackendFieldsForView(viewName, screenClasses);
+ const hostContext = loadHtmlFieldMetadataContext(tsFilePaths);
+ if (!hostContext) {
+ return undefined;
+ }
+
+ const fieldReference = parseFieldReference(fieldName);
+ const workspaceRoots = vscode.workspace.workspaceFolders?.map(folder => folder.uri.fsPath);
+ const includeContext = getIncludeFieldContext({
+ documentPath: htmlFilePath,
+ elementNode,
+ hostTsFilePaths: tsFilePaths,
+ hostScreenClasses: hostContext.screenClasses,
+ workspaceRoots,
+ });
+ const includeResolution = includeContext
+ ? resolveHtmlField({
+ fieldReference,
+ elementNode,
+ metadataContext: includeContext,
+ selectorDocument: includeContext.templateDocument,
+ parameterValues: includeContext.parameterValues,
+ allowAnyViewWhenUnscoped: true,
+ useParentView: false,
+ })
+ : undefined;
+ const hostIncludeSelectorResolution = includeContext
+ ? resolveHtmlField({
+ fieldReference,
+ elementNode,
+ metadataContext: hostContext,
+ selectorDocument: includeContext.templateDocument,
+ useParentView: options.useParentView,
+ })
+ : undefined;
+ const hostBaseResolution = resolveHtmlField({
+ fieldReference,
+ elementNode,
+ metadataContext: hostContext,
+ selectorDocument: getBaseScreenDocument(htmlFilePath),
+ useParentView: options.useParentView,
+ });
+ const hostResolution = hostIncludeSelectorResolution?.viewResolution
+ ? hostIncludeSelectorResolution
+ : hostBaseResolution;
+ const hoverResolution =
+ includeResolution?.viewName && includeResolution.viewResolution && !includeResolution.hasTemplatedBinding
+ ? includeResolution
+ : hostResolution;
+ if (!hoverResolution?.viewName || hoverResolution.hasTemplatedBinding) {
+ return undefined;
+ }
+
+ const backendFields = await loadBackendFieldsForView(
+ hoverResolution.viewName,
+ hoverResolution === includeResolution
+ ? includeContext?.hostScreenClasses ?? hostContext.screenClasses
+ : hostContext.screenClasses
+ );
if (!backendFields?.size) {
return undefined;
}
- const normalizedFieldName = normalizeMetaName(fieldName);
+ const normalizedFieldName = normalizeMetaName(hoverResolution.fieldName);
if (!normalizedFieldName) {
return undefined;
}
@@ -125,7 +163,7 @@ async function buildFieldHover(
return undefined;
}
- const markdown = buildFieldMarkdown(backendField, fieldName, viewName);
+ const markdown = buildFieldMarkdown(backendField, hoverResolution.fieldName, hoverResolution.viewName);
if (!markdown) {
return undefined;
}
@@ -156,4 +194,4 @@ function buildFieldMarkdown(
const markdown = new vscode.MarkdownString(lines.join('\n'));
markdown.isTrusted = false;
return markdown;
-}
\ No newline at end of file
+}
diff --git a/acumate-plugin/src/providers/html-shared.ts b/acumate-plugin/src/providers/html-shared.ts
index f679383..ac917c3 100644
--- a/acumate-plugin/src/providers/html-shared.ts
+++ b/acumate-plugin/src/providers/html-shared.ts
@@ -234,30 +234,47 @@ export function readAttributeAtOffset(text: string, offset: number) {
};
}
-// Climbs ancestors until a view.bind is found, mirroring runtime scoping.
-export function findParentViewName(node: any): string | undefined {
- let current: any = node?.parent ?? node?.parentNode;
- while (current) {
- const viewBinding = current.attribs?.['view.bind'];
- if (typeof viewBinding === 'string' && viewBinding.length) {
- return viewBinding;
+export function getElementViewName(node: any): string | undefined {
+ const viewBinding = node?.attribs?.['view.bind'];
+ if (typeof viewBinding === 'string' && viewBinding.trim().length) {
+ return viewBinding.trim();
+ }
+
+ if (node?.name === 'qp-panel') {
+ const panelId = node.attribs?.['id'];
+ if (typeof panelId === 'string' && panelId.trim().length) {
+ return panelId.trim();
}
+ }
- if (current.name === 'qp-panel') {
- const panelId = current.attribs?.['id'];
- if (typeof panelId === 'string' && panelId.trim().length) {
- return panelId.trim();
- }
+ if (node?.name === 'using') {
+ const usingView = node.attribs?.view;
+ if (typeof usingView === 'string' && usingView.trim().length) {
+ return usingView.trim();
}
+ }
- if (current.name === 'using') {
- const usingView = current.attribs?.view;
- if (usingView) {
- return usingView;
- }
+ return undefined;
+}
+
+export function findViewNameAtOrAbove(node: any): string | undefined {
+ let current: any = node;
+ while (current) {
+ const viewName = getElementViewName(current);
+ if (viewName) {
+ return viewName;
}
current = current.parent ?? current.parentNode;
}
return undefined;
}
+
+// Climbs ancestors until a view scope is found, mirroring runtime scoping.
+export function findParentViewName(node: any): string | undefined {
+ return findViewNameAtOrAbove(node?.parent ?? node?.parentNode);
+}
+
+export function isActionStateBindTag(tagName: string | undefined): boolean {
+ return typeof tagName === 'string' && tagName.toLowerCase() === 'qp-button';
+}
diff --git a/acumate-plugin/src/services/client-controls-service.ts b/acumate-plugin/src/services/client-controls-service.ts
index e51d840..844c8d2 100644
--- a/acumate-plugin/src/services/client-controls-service.ts
+++ b/acumate-plugin/src/services/client-controls-service.ts
@@ -17,6 +17,10 @@ export interface ControlConfigDefinition {
properties: ControlConfigProperty[];
}
+interface InterfaceDeclarationInfo extends ControlConfigDefinition {
+ baseTypes: string[];
+}
+
export interface ClientControlConfigInfo {
typeName: string;
displayName: string;
@@ -150,7 +154,7 @@ function collectClientControls(packageRoot: string): ClientControlMetadata[] {
}
const sourceFiles: ts.SourceFile[] = [];
- const interfaceMap = new Map();
+ const interfaceDeclarations = new Map();
for (const filePath of declarationFiles) {
const sourceFile = parseSourceFile(filePath);
@@ -158,9 +162,10 @@ function collectClientControls(packageRoot: string): ClientControlMetadata[] {
continue;
}
sourceFiles.push(sourceFile);
- collectInterfaceDeclarations(sourceFile, interfaceMap);
+ collectInterfaceDeclarations(sourceFile, interfaceDeclarations);
}
+ const interfaceMap = resolveInterfaceDefinitions(interfaceDeclarations);
const controls = new Map();
for (const sourceFile of sourceFiles) {
collectControlDeclarations(sourceFile, packageRoot, interfaceMap, controls);
@@ -223,7 +228,7 @@ function parseSourceFile(filePath: string): ts.SourceFile | undefined {
}
}
-function collectInterfaceDeclarations(sourceFile: ts.SourceFile, interfaceMap: Map) {
+function collectInterfaceDeclarations(sourceFile: ts.SourceFile, interfaceMap: Map) {
const visit = (node: ts.Node) => {
if (ts.isInterfaceDeclaration(node)) {
const name = node.name.text;
@@ -243,10 +248,12 @@ function collectInterfaceDeclarations(sourceFile: ts.SourceFile, interfaceMap: M
description: extractJsDoc(node.getSourceFile(), member),
});
}
+ const existing = interfaceMap.get(name);
interfaceMap.set(name, {
typeName: name,
- description: extractJsDoc(sourceFile, node),
- properties,
+ description: existing?.description ?? extractJsDoc(sourceFile, node),
+ properties: [...(existing?.properties ?? []), ...properties],
+ baseTypes: [...(existing?.baseTypes ?? []), ...getInterfaceBaseTypes(node)],
});
}
@@ -256,6 +263,89 @@ function collectInterfaceDeclarations(sourceFile: ts.SourceFile, interfaceMap: M
visit(sourceFile);
}
+function getInterfaceBaseTypes(node: ts.InterfaceDeclaration): string[] {
+ const baseTypes: string[] = [];
+ for (const clause of node.heritageClauses ?? []) {
+ if (clause.token !== ts.SyntaxKind.ExtendsKeyword) {
+ continue;
+ }
+
+ for (const heritageType of clause.types) {
+ const baseName = getHeritageTypeName(heritageType);
+ if (baseName) {
+ baseTypes.push(baseName);
+ }
+ }
+ }
+
+ return baseTypes;
+}
+
+function getHeritageTypeName(heritageType: ts.ExpressionWithTypeArguments): string | undefined {
+ const expression = heritageType.expression;
+ if (ts.isIdentifier(expression)) {
+ return expression.text;
+ }
+
+ if (ts.isPropertyAccessExpression(expression)) {
+ return expression.name.text;
+ }
+
+ return undefined;
+}
+
+function resolveInterfaceDefinitions(
+ interfaceDeclarations: Map
+): Map {
+ const resolved = new Map();
+ for (const typeName of interfaceDeclarations.keys()) {
+ resolveInterfaceDefinition(typeName, interfaceDeclarations, resolved, new Set());
+ }
+
+ return resolved;
+}
+
+function resolveInterfaceDefinition(
+ typeName: string,
+ interfaceDeclarations: Map,
+ resolved: Map,
+ active: Set
+): ControlConfigDefinition | undefined {
+ const cached = resolved.get(typeName);
+ if (cached) {
+ return cached;
+ }
+
+ const declaration = interfaceDeclarations.get(typeName);
+ if (!declaration || active.has(typeName)) {
+ return undefined;
+ }
+
+ active.add(typeName);
+ const properties = new Map();
+ for (const baseType of declaration.baseTypes) {
+ const baseDefinition = resolveInterfaceDefinition(baseType, interfaceDeclarations, resolved, active);
+ for (const property of baseDefinition?.properties ?? []) {
+ if (!properties.has(property.name)) {
+ properties.set(property.name, property);
+ }
+ }
+ }
+
+ for (const property of declaration.properties) {
+ properties.set(property.name, property);
+ }
+
+ active.delete(typeName);
+ const definition: ControlConfigDefinition = {
+ typeName: declaration.typeName,
+ description: declaration.description,
+ properties: [...properties.values()],
+ };
+ resolved.set(typeName, definition);
+ return definition;
+}
+
function collectControlDeclarations(
sourceFile: ts.SourceFile,
packageRoot: string,
diff --git a/acumate-plugin/src/services/html-field-context-service.ts b/acumate-plugin/src/services/html-field-context-service.ts
new file mode 100644
index 0000000..889ed07
--- /dev/null
+++ b/acumate-plugin/src/services/html-field-context-service.ts
@@ -0,0 +1,516 @@
+import path from 'path';
+
+import {
+ ClassPropertyInfo,
+ CollectedClassInfo,
+ ViewResolution,
+ createClassInfoLookup,
+ filterClassesBySource,
+ filterScreenLikeClasses,
+ getRelatedTsFiles,
+ loadClassInfosFromFiles,
+ resolveClassInfoForProperty,
+ resolveViewBinding,
+} from '../utils';
+import { findParentViewName, findViewNameAtOrAbove } from '../providers/html-shared';
+import { resolveIncludeFilePath } from './include-service';
+import {
+ BaseScreenDocument,
+ createParameterizedHtmlDocument,
+ getCustomizationSelectorAttributes,
+ isCustomizationSelectorAttribute,
+ loadHtmlDocument,
+ queryBaseScreenElements,
+} from './screen-html-service';
+
+export interface HtmlFieldMetadataContext {
+ classInfoLookup: Map;
+ screenClasses: CollectedClassInfo[];
+ viewResolutionCache?: Map;
+}
+
+export interface HtmlIncludeTemplateFieldContext extends HtmlFieldMetadataContext {
+ templateDocument?: BaseScreenDocument;
+ viewResolutionCache: Map;
+}
+
+export interface HtmlIncludeFieldContext extends HtmlIncludeTemplateFieldContext {
+ includeNode: any;
+ includePath: string;
+ parameterValues: Map;
+ hostScreenClasses?: CollectedClassInfo[];
+}
+
+export interface FieldReference {
+ viewName?: string;
+ fieldName: string;
+}
+
+export interface HtmlFieldResolution {
+ fieldName: string;
+ viewName?: string;
+ viewResolution?: ViewResolution;
+ fieldProperty?: ClassPropertyInfo;
+ usedAnyViewFallback: boolean;
+ hasTemplatedBinding: boolean;
+}
+
+export interface FieldInViewResolution {
+ viewName: string;
+ viewResolution: ViewResolution;
+ fieldProperty: ClassPropertyInfo;
+}
+
+export type HtmlIncludeTemplateFieldContextCache =
+ Map;
+
+export function createHtmlFieldMetadataContext(
+ classInfos: CollectedClassInfo[],
+ sourceFilePaths: string[]
+): HtmlFieldMetadataContext {
+ const relevantClassInfos = filterClassesBySource(classInfos, sourceFilePaths);
+ return {
+ classInfoLookup: createClassInfoLookup(classInfos),
+ screenClasses: filterScreenLikeClasses(relevantClassInfos),
+ viewResolutionCache: new Map(),
+ };
+}
+
+export function loadHtmlFieldMetadataContext(
+ tsFilePaths: string[]
+): HtmlFieldMetadataContext | undefined {
+ if (!tsFilePaths.length) {
+ return undefined;
+ }
+
+ const classInfos = loadClassInfosFromFiles(tsFilePaths);
+ if (!classInfos.length) {
+ return undefined;
+ }
+
+ const context = createHtmlFieldMetadataContext(classInfos, tsFilePaths);
+ if (!context.screenClasses.length) {
+ return undefined;
+ }
+
+ return context;
+}
+
+export function resolveHtmlView(
+ viewName: string | undefined,
+ context: HtmlFieldMetadataContext
+): ViewResolution | undefined {
+ if (!viewName) {
+ return undefined;
+ }
+
+ const cache = context.viewResolutionCache;
+ if (cache?.has(viewName)) {
+ return cache.get(viewName);
+ }
+
+ const resolution = resolveViewBinding(viewName, context.screenClasses, context.classInfoLookup);
+ cache?.set(viewName, resolution);
+ return resolution;
+}
+
+export function parseFieldReference(rawFieldName: string): FieldReference {
+ const trimmed = rawFieldName.trim();
+ const dotIndex = trimmed.indexOf('.');
+ if (dotIndex === -1) {
+ return { fieldName: trimmed };
+ }
+
+ const viewName = trimmed.substring(0, dotIndex).trim();
+ const fieldName = trimmed.substring(dotIndex + 1).trim();
+ return { viewName, fieldName };
+}
+
+export function resolveHtmlField(
+ options: {
+ rawFieldName?: string;
+ fieldReference?: FieldReference;
+ elementNode: any;
+ metadataContext: HtmlFieldMetadataContext;
+ selectorDocument?: BaseScreenDocument;
+ parameterValues?: Map;
+ allowAnyViewFallback?: boolean;
+ allowAnyViewWhenUnscoped?: boolean;
+ useParentView?: boolean;
+ }
+): HtmlFieldResolution | undefined {
+ const fieldReference =
+ options.fieldReference ??
+ (options.rawFieldName ? parseFieldReference(options.rawFieldName) : undefined);
+ if (!fieldReference) {
+ return undefined;
+ }
+
+ let viewName = fieldReference.viewName;
+ let fieldName = fieldReference.fieldName;
+ let allowAnyViewFallback = Boolean(options.allowAnyViewFallback);
+
+ if (!viewName && options.useParentView !== false) {
+ viewName = findParentViewName(options.elementNode);
+ }
+
+ if (!viewName) {
+ const selectorViewName = getViewNameFromCustomizationSelectors(
+ options.elementNode,
+ options.selectorDocument
+ );
+ allowAnyViewFallback ||= hasTemplateExpression(selectorViewName);
+ viewName = selectorViewName;
+ }
+
+ if (options.parameterValues) {
+ allowAnyViewFallback ||= hasTemplateExpression(viewName);
+ viewName = resolveTemplateValue(viewName, options.parameterValues);
+ fieldName = resolveTemplateValue(fieldName, options.parameterValues) ?? fieldName;
+ }
+
+ const hasTemplatedBinding =
+ hasTemplateExpression(viewName) ||
+ hasTemplateExpression(fieldName);
+ if (!fieldName || hasTemplatedBinding) {
+ return {
+ fieldName,
+ viewName,
+ usedAnyViewFallback: false,
+ hasTemplatedBinding,
+ };
+ }
+
+ if (viewName) {
+ const viewResolution = resolveHtmlView(viewName, options.metadataContext);
+ const fieldProperty = viewResolution?.viewClass?.properties.get(fieldName);
+ if (fieldProperty?.kind === 'field') {
+ return {
+ fieldName,
+ viewName,
+ viewResolution,
+ fieldProperty,
+ usedAnyViewFallback: false,
+ hasTemplatedBinding: false,
+ };
+ }
+
+ if (!allowAnyViewFallback) {
+ return {
+ fieldName,
+ viewName,
+ viewResolution,
+ usedAnyViewFallback: false,
+ hasTemplatedBinding: false,
+ };
+ }
+ }
+
+ if (allowAnyViewFallback || (!viewName && options.allowAnyViewWhenUnscoped)) {
+ const anyViewResolution = findFieldInAnyView(fieldName, options.metadataContext);
+ if (anyViewResolution) {
+ return {
+ fieldName,
+ viewName: anyViewResolution.viewName,
+ viewResolution: anyViewResolution.viewResolution,
+ fieldProperty: anyViewResolution.fieldProperty,
+ usedAnyViewFallback: true,
+ hasTemplatedBinding: false,
+ };
+ }
+ }
+
+ return {
+ fieldName,
+ viewName,
+ usedAnyViewFallback: Boolean(allowAnyViewFallback),
+ hasTemplatedBinding: false,
+ };
+}
+
+export function findFieldInAnyView(
+ fieldName: string | undefined,
+ context: HtmlFieldMetadataContext
+): FieldInViewResolution | undefined {
+ return findFieldsInAnyView(fieldName, context)[0];
+}
+
+export function findFieldsInAnyView(
+ fieldName: string | undefined,
+ context: HtmlFieldMetadataContext
+): FieldInViewResolution[] {
+ if (!fieldName || hasTemplateExpression(fieldName)) {
+ return [];
+ }
+
+ const matches: FieldInViewResolution[] = [];
+ const seen = new Set();
+ for (const screenClass of context.screenClasses) {
+ for (const [viewName, property] of screenClass.properties) {
+ if (property.kind !== 'view' && property.kind !== 'viewCollection') {
+ continue;
+ }
+
+ const viewResolution = resolveViewBinding(viewName, [screenClass], context.classInfoLookup);
+ const viewClass =
+ viewResolution?.viewClass ??
+ resolveClassInfoForProperty(property, context.classInfoLookup);
+ const fieldProperty = viewClass?.properties.get(fieldName);
+ if (fieldProperty?.kind === 'field') {
+ const key = `${fieldProperty.sourceFile.fileName}:${fieldProperty.node.getStart()}`;
+ if (seen.has(key)) {
+ continue;
+ }
+ seen.add(key);
+ matches.push({
+ viewName,
+ viewResolution: viewResolution ?? { screenClass, property, viewClass },
+ fieldProperty,
+ });
+ }
+ }
+ }
+
+ return matches;
+}
+
+export function getFieldPropertiesFromViews(
+ context: HtmlFieldMetadataContext
+): Map {
+ const fields = new Map();
+ for (const screenClass of context.screenClasses) {
+ for (const [viewName, property] of screenClass.properties) {
+ if (property.kind !== 'view' && property.kind !== 'viewCollection') {
+ continue;
+ }
+
+ const viewClass =
+ resolveViewBinding(viewName, [screenClass], context.classInfoLookup)?.viewClass ??
+ resolveClassInfoForProperty(property, context.classInfoLookup);
+ if (!viewClass) {
+ continue;
+ }
+
+ for (const [fieldName, fieldProperty] of viewClass.properties) {
+ if (fieldProperty.kind === 'field' && !fields.has(fieldName)) {
+ fields.set(fieldName, fieldProperty);
+ }
+ }
+ }
+ }
+ return fields;
+}
+
+export function getIncludeFieldContext(options: {
+ documentPath: string;
+ hostTsFilePaths: string[];
+ elementNode?: any;
+ includeNode?: any;
+ workspaceRoots?: string[];
+ hostScreenClasses?: CollectedClassInfo[];
+ cache?: HtmlIncludeTemplateFieldContextCache;
+}): HtmlIncludeFieldContext | undefined {
+ const includeNode = options.includeNode ?? findNearestIncludeNode(options.elementNode);
+ const includeUrl = includeNode?.attribs?.url;
+ if (typeof includeUrl !== 'string' || !includeUrl.length || hasTemplateExpression(includeUrl)) {
+ return undefined;
+ }
+
+ const includePath = resolveIncludeFilePath(includeUrl, options.documentPath, options.workspaceRoots);
+ if (!includePath) {
+ return undefined;
+ }
+
+ const normalizedIncludePath = path.normalize(includePath);
+ const cacheKey = [
+ normalizedIncludePath,
+ ...options.hostTsFilePaths.map(filePath => path.normalize(filePath)),
+ ].join('|');
+ const cache = options.cache;
+ let templateContext: HtmlIncludeTemplateFieldContext | undefined;
+ if (cache) {
+ templateContext = cache.get(cacheKey);
+ }
+ if (cache && !cache.has(cacheKey)) {
+ templateContext = loadIncludeTemplateFieldContext(normalizedIncludePath, options.hostTsFilePaths);
+ cache.set(cacheKey, templateContext);
+ }
+ if (!cache) {
+ templateContext = loadIncludeTemplateFieldContext(normalizedIncludePath, options.hostTsFilePaths);
+ }
+
+ if (!templateContext) {
+ return undefined;
+ }
+
+ const parameterValues = getIncludeParameterValues(includeNode);
+ const templateDocument = templateContext.templateDocument
+ ? createParameterizedHtmlDocument(templateContext.templateDocument, parameterValues)
+ : undefined;
+
+ return {
+ ...templateContext,
+ templateDocument,
+ includeNode,
+ includePath: normalizedIncludePath,
+ parameterValues,
+ hostScreenClasses: options.hostScreenClasses,
+ };
+}
+
+export function loadIncludeTemplateFieldContext(
+ includeHtmlPath: string,
+ hostTsFilePaths: string[]
+): HtmlIncludeTemplateFieldContext | undefined {
+ const includeTsFilePaths = getRelatedTsFiles(includeHtmlPath);
+ const combinedTsFilePaths = dedupeFilePaths([...hostTsFilePaths, ...includeTsFilePaths]);
+ const classInfos = combinedTsFilePaths.length
+ ? loadClassInfosFromFiles(combinedTsFilePaths)
+ : [];
+ const metadataContext = createHtmlFieldMetadataContext(classInfos, includeTsFilePaths);
+ const templateDocument = loadHtmlDocument(includeHtmlPath);
+
+ if (!metadataContext.screenClasses.length && !templateDocument) {
+ return undefined;
+ }
+
+ return {
+ ...metadataContext,
+ templateDocument,
+ viewResolutionCache: new Map(),
+ };
+}
+
+export function findNearestIncludeNode(node: any): any | undefined {
+ let current = node;
+ while (current) {
+ if (current.type === 'tag' && current.name === 'qp-include') {
+ return current;
+ }
+
+ current = current.parent ?? current.parentNode;
+ }
+ return undefined;
+}
+
+export function getIncludeParameterValues(includeNode: any): Map {
+ const values = new Map();
+ const attributes = includeNode?.attribs ?? {};
+ for (const [attributeName, attributeValue] of Object.entries(attributes)) {
+ if (typeof attributeValue === 'string') {
+ values.set(attributeName, attributeValue);
+ }
+ }
+ return values;
+}
+
+export function getViewNameFromCustomizationSelectors(
+ node: any,
+ document: BaseScreenDocument | undefined
+): string | undefined {
+ if (!document?.dom?.length) {
+ return undefined;
+ }
+
+ const attributes = node?.attribs;
+ if (!attributes) {
+ return undefined;
+ }
+
+ for (const attributeName of getCustomizationSelectorAttributes()) {
+ const rawValue = attributes[attributeName];
+ if (typeof rawValue !== 'string') {
+ continue;
+ }
+
+ const normalizedValue = rawValue.trim();
+ if (!normalizedValue.length || hasTemplateExpression(normalizedValue)) {
+ continue;
+ }
+
+ const { nodes, error } = queryBaseScreenElements(document, normalizedValue);
+ if (error || !nodes.length) {
+ continue;
+ }
+
+ for (const target of nodes) {
+ const viewName = findViewNameAtOrAbove(target);
+ if (viewName) {
+ return viewName;
+ }
+ }
+ }
+
+ return undefined;
+}
+
+export function getParentOrSelectorViewName(
+ node: any,
+ selectorDocument: BaseScreenDocument | undefined
+): string | undefined {
+ return findParentViewName(node) ?? getViewNameFromCustomizationSelectors(node, selectorDocument);
+}
+
+export function forEachCustomizationSelector(
+ node: any,
+ callback: (attributeName: string, rawValue: string, normalizedValue: string) => void
+) {
+ if (!node?.attribs) {
+ return;
+ }
+
+ for (const [attributeName, attributeValue] of Object.entries(node.attribs)) {
+ if (!isCustomizationSelectorAttribute(attributeName) || typeof attributeValue !== 'string') {
+ continue;
+ }
+
+ const normalizedValue = attributeValue.trim();
+ if (!normalizedValue.length) {
+ continue;
+ }
+
+ callback(attributeName, attributeValue, normalizedValue);
+ }
+}
+
+export function hasTemplateCustomizationSelector(node: any): boolean {
+ let hasTemplateSelector = false;
+ forEachCustomizationSelector(node, (_attributeName, _rawValue, normalizedValue) => {
+ if (hasTemplateExpression(normalizedValue)) {
+ hasTemplateSelector = true;
+ }
+ });
+ return hasTemplateSelector;
+}
+
+export function resolveTemplateValue(
+ value: string | undefined,
+ parameterValues: Map
+): string | undefined {
+ if (!value) {
+ return value;
+ }
+
+ return value.replace(/{{\s*([^}\s]+)\s*}}/g, (match, parameterName: string) => {
+ const parameterValue = parameterValues.get(parameterName)?.trim();
+ return parameterValue || match;
+ }).trim();
+}
+
+export function hasTemplateExpression(value: string | undefined): boolean {
+ return typeof value === 'string' && /{{\s*[^}]+\s*}}/.test(value);
+}
+
+export function dedupeFilePaths(filePaths: string[]): string[] {
+ const seen = new Set();
+ const result: string[] = [];
+ for (const filePath of filePaths) {
+ const normalized = path.normalize(filePath);
+ if (seen.has(normalized)) {
+ continue;
+ }
+ seen.add(normalized);
+ result.push(filePath);
+ }
+ return result;
+}
diff --git a/acumate-plugin/src/services/screen-html-service.ts b/acumate-plugin/src/services/screen-html-service.ts
index 06cf010..d0cc4f3 100644
--- a/acumate-plugin/src/services/screen-html-service.ts
+++ b/acumate-plugin/src/services/screen-html-service.ts
@@ -1,5 +1,6 @@
import fs from "fs";
import path from "path";
+import ts from "typescript";
import { DomHandler, Parser } from "htmlparser2";
import { selectAll } from "css-select";
@@ -7,14 +8,22 @@ export interface BaseScreenDocument {
filePath: string;
content: string;
dom: any[];
+ sourceDocuments?: readonly BaseScreenDocument[];
+ nodeDocumentMap?: WeakMap