From 50c697f3a06cb8f4f8a73e2ed00f614fb4a6ad87 Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Fri, 8 May 2026 13:08:38 +0200 Subject: [PATCH 01/21] include-aware qp-include field validation --- .../src/services/screen-html-service.ts | 15 +- .../TestIncludeFieldModificationHost.html | 6 + .../html/TestIncludeFieldModificationHost.ts | 7 + ...stIncludeFieldModificationHostInvalid.html | 5 + ...TestIncludeFieldModificationHostInvalid.ts | 7 + .../include-field-modification-template.html | 5 + .../include-field-modification-template.ts | 9 + .../src/test/suite/htmlValidation.test.ts | 21 ++ .../htmlValidation/html-validation.ts | 228 +++++++++++++++++- 9 files changed, 292 insertions(+), 11 deletions(-) create mode 100644 acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHost.html create mode 100644 acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHost.ts create mode 100644 acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHostInvalid.html create mode 100644 acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHostInvalid.ts create mode 100644 acumate-plugin/src/test/fixtures/includes/include-field-modification-template.html create mode 100644 acumate-plugin/src/test/fixtures/includes/include-field-modification-template.ts diff --git a/acumate-plugin/src/services/screen-html-service.ts b/acumate-plugin/src/services/screen-html-service.ts index 06cf010..75d3b07 100644 --- a/acumate-plugin/src/services/screen-html-service.ts +++ b/acumate-plugin/src/services/screen-html-service.ts @@ -40,17 +40,22 @@ export function getBaseScreenDocument(htmlFilePath: string): BaseScreenDocument return undefined; } - const mtime = tryGetMtime(baseHtmlPath); - const cached = cache.get(baseHtmlPath); + return loadHtmlDocument(baseHtmlPath); +} + +export function loadHtmlDocument(htmlFilePath: string): BaseScreenDocument | undefined { + const normalizedPath = path.normalize(htmlFilePath); + const mtime = tryGetMtime(normalizedPath); + const cached = cache.get(normalizedPath); if (cached && cached.mtime === mtime) { return cached; } try { - const content = fs.readFileSync(baseHtmlPath, "utf-8"); + const content = fs.readFileSync(normalizedPath, "utf-8"); const dom = parseHtml(content); - const entry: CachedDocument = { filePath: baseHtmlPath, content, dom, mtime }; - cache.set(baseHtmlPath, entry); + const entry: CachedDocument = { filePath: normalizedPath, content, dom, mtime }; + cache.set(normalizedPath, entry); return entry; } catch { diff --git a/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHost.html b/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHost.html new file mode 100644 index 0000000..59e5e9b --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHost.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHost.ts b/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHost.ts new file mode 100644 index 0000000..a8178d5 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHost.ts @@ -0,0 +1,7 @@ +export class IncludeHostView extends PXView { + HostField!: PXFieldState; +} + +export class IncludeHostMaint extends PXScreen { + hostView!: PXView; +} diff --git a/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHostInvalid.html b/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHostInvalid.html new file mode 100644 index 0000000..b9d5093 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHostInvalid.html @@ -0,0 +1,5 @@ + + + + + diff --git a/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHostInvalid.ts b/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHostInvalid.ts new file mode 100644 index 0000000..c1f753d --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHostInvalid.ts @@ -0,0 +1,7 @@ +export class InvalidIncludeHostView extends PXView { + HostField!: PXFieldState; +} + +export class InvalidIncludeHostMaint extends PXScreen { + hostView!: PXView; +} diff --git a/acumate-plugin/src/test/fixtures/includes/include-field-modification-template.html b/acumate-plugin/src/test/fixtures/includes/include-field-modification-template.html new file mode 100644 index 0000000..ecc296b --- /dev/null +++ b/acumate-plugin/src/test/fixtures/includes/include-field-modification-template.html @@ -0,0 +1,5 @@ + + + + + diff --git a/acumate-plugin/src/test/fixtures/includes/include-field-modification-template.ts b/acumate-plugin/src/test/fixtures/includes/include-field-modification-template.ts new file mode 100644 index 0000000..86cb17e --- /dev/null +++ b/acumate-plugin/src/test/fixtures/includes/include-field-modification-template.ts @@ -0,0 +1,9 @@ +export class IncludeModificationView extends PXView { + Anchor!: PXFieldState; + IncludedAlpha!: PXFieldState; + IncludedBeta!: PXFieldState; +} + +export class IncludeModificationMaint extends PXScreen { + includedView!: PXView; +} diff --git a/acumate-plugin/src/test/suite/htmlValidation.test.ts b/acumate-plugin/src/test/suite/htmlValidation.test.ts index ab24bb1..25e26aa 100644 --- a/acumate-plugin/src/test/suite/htmlValidation.test.ts +++ b/acumate-plugin/src/test/suite/htmlValidation.test.ts @@ -247,6 +247,27 @@ describe('HTML validation diagnostics', () => { ); }); + it('accepts field modifications that target a qp-include template TS model', async () => { + const document = await openFixtureDocument('TestIncludeFieldModificationHost.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.strictEqual( + diagnostics.filter(d => d.message.includes('') || d.message.includes('field "')).length, + 0, + 'Expected no field diagnostics when qp-include children use fields from include TS metadata' + ); + }); + + it('reports qp-include field modifications missing from host and include TS models', async () => { + const document = await openFixtureDocument('TestIncludeFieldModificationHostInvalid.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.ok( + diagnostics.some(d => d.message.includes('') || d.message.includes('field "')), + 'Expected field diagnostic when qp-include child field is unknown in include TS metadata' + ); + }); + it('accepts qp-template name values defined by ScreenTemplates', async () => { const document = await openFixtureDocument('TestQpTemplate.html'); await validateHtmlFile(document); diff --git a/acumate-plugin/src/validation/htmlValidation/html-validation.ts b/acumate-plugin/src/validation/htmlValidation/html-validation.ts index f8ebdfd..7581c67 100644 --- a/acumate-plugin/src/validation/htmlValidation/html-validation.ts +++ b/acumate-plugin/src/validation/htmlValidation/html-validation.ts @@ -15,7 +15,7 @@ import { filterClassesBySource, } from "../../utils"; import { findParentViewName } from "../../providers/html-shared"; -import { getIncludeMetadata } from "../../services/include-service"; +import { getIncludeMetadata, resolveIncludeFilePath } from "../../services/include-service"; import { getScreenTemplates } from "../../services/screen-template-service"; import { getClientControlsMetadata, ClientControlMetadata } from "../../services/client-controls-service"; import { AcuMateContext } from "../../plugin-context"; @@ -25,6 +25,7 @@ import { queryBaseScreenElements, BaseScreenDocument, getCustomizationSelectorAttributes, + loadHtmlDocument, } from "../../services/screen-html-service"; import { createSuppressionEngine, SuppressionEngine } from "../../diagnostics/suppression"; @@ -33,6 +34,19 @@ import { createSuppressionEngine, SuppressionEngine } from "../../diagnostics/su const includeIntrinsicAttributes = new Set(["id", "class", "style", "slot"]); const idOptionalTags = new Set(["qp-field", "qp-label", "qp-include"]); +interface IncludeTemplateFieldValidationContext { + classInfoMap: Map; + screenClasses: CollectedClassInfo[]; + templateDocument?: BaseScreenDocument; + viewResolutionCache: Map; +} + +interface IncludeFieldValidationContext extends IncludeTemplateFieldValidationContext { + parameterValues: Map; +} + +type IncludeTemplateFieldValidationCache = Map; + function pushHtmlDiagnostic( diagnostics: vscode.Diagnostic[], suppression: SuppressionEngine, @@ -104,7 +118,9 @@ export async function validateHtmlFile(document: vscode.TextDocument) { baseScreenDocument, suppression, undefined, - false + false, + undefined, + new Map() ); } }, @@ -137,7 +153,9 @@ function validateDom( baseScreenDocument: BaseScreenDocument | undefined, suppression: SuppressionEngine, panelViewContext?: CollectedClassInfo, - isInsideDataFeed = false + isInsideDataFeed = false, + includeFieldContext: IncludeFieldValidationContext | undefined = undefined, + includeFieldContextCache: IncludeTemplateFieldValidationCache = new Map() ) { const classInfoMap = createClassInfoLookup(classProperties); const screenClasses = filterScreenLikeClasses(relevantClassInfos); @@ -162,9 +180,72 @@ function validateDom( return resolution; } + function getIncludeFieldValidationContext(node: any): IncludeFieldValidationContext | undefined { + const includeUrl = node.attribs?.url; + if (typeof includeUrl !== "string" || !includeUrl.length) { + return undefined; + } + + const includeHtmlPath = resolveIncludeFilePath(includeUrl, htmlFilePath, workspaceRoots); + if (!includeHtmlPath) { + return undefined; + } + + const normalizedIncludePath = path.normalize(includeHtmlPath); + let templateContext = includeFieldContextCache.get(normalizedIncludePath); + if (!includeFieldContextCache.has(normalizedIncludePath)) { + templateContext = loadIncludeTemplateFieldValidationContext(normalizedIncludePath); + includeFieldContextCache.set(normalizedIncludePath, templateContext); + } + + if (!templateContext) { + return undefined; + } + + return { + ...templateContext, + parameterValues: getIncludeParameterValues(node), + }; + } + + function loadIncludeTemplateFieldValidationContext( + includeHtmlPath: string + ): IncludeTemplateFieldValidationContext | undefined { + const includeTsFilePaths = getRelatedTsFiles(includeHtmlPath); + const includeClassProperties = includeTsFilePaths.length + ? loadClassInfosFromFiles(includeTsFilePaths) + : []; + const includeRelevantClassInfos = filterClassesBySource(includeClassProperties, includeTsFilePaths); + const screenClasses = filterScreenLikeClasses(includeRelevantClassInfos); + const templateDocument = loadHtmlDocument(includeHtmlPath); + + if (!screenClasses.length && !templateDocument) { + return undefined; + } + + return { + classInfoMap: createClassInfoLookup(includeClassProperties), + screenClasses, + templateDocument, + viewResolutionCache: new Map(), + }; + } + + function getIncludeParameterValues(node: any): Map { + const values = new Map(); + const attributes = node.attribs ?? {}; + for (const [attributeName, attributeValue] of Object.entries(attributes)) { + if (typeof attributeValue === "string") { + values.set(attributeName, attributeValue); + } + } + return values; + } + // Custom validation logic goes here dom.forEach((node) => { let nextPanelViewContext = panelViewContext; + let nextIncludeFieldContext = includeFieldContext; const normalizedTagName = node.type === "tag" && typeof node.name === "string" ? node.name.toLowerCase() : ""; const elementId = node.type === "tag" ? getElementId(node) : ""; @@ -265,6 +346,7 @@ function validateDom( if (node.type === "tag" && node.name === "qp-include") { validateIncludeNode(node, diagnostics, content, htmlFilePath, workspaceRoots, suppression); + nextIncludeFieldContext = getIncludeFieldValidationContext(node); } if ( @@ -317,14 +399,36 @@ function validateDom( if (!isUnboundReplacement) { let viewName = viewSpecified ? viewFromNameAttribute : findParentViewName(node); + let includeViewNameAllowsAnyViewFallback = false; if (!viewName) { viewName = getViewNameFromCustomizationSelectors(node); } - const fieldName = viewSpecified ? fieldFromNameAttribute : node.attribs.name; + if (!viewName && includeFieldContext) { + const includeViewName = getViewNameFromIncludeCustomizationSelectors(node, includeFieldContext); + includeViewNameAllowsAnyViewFallback = hasIncludeTemplateExpression(includeViewName); + viewName = includeViewName; + } + + if (includeFieldContext) { + includeViewNameAllowsAnyViewFallback ||= hasIncludeTemplateExpression(viewName); + viewName = resolveIncludeTemplateValue(viewName, includeFieldContext); + } + + const rawFieldName = viewSpecified ? fieldFromNameAttribute : node.attribs.name; + const fieldName = includeFieldContext + ? resolveIncludeTemplateValue(rawFieldName, includeFieldContext) + : rawFieldName; const viewResolution = resolveView(viewName); const viewClass = viewResolution?.viewClass; const fieldProperty = viewClass?.properties.get(fieldName); - const isValidField = fieldProperty?.kind === "field"; + const isValidField = + fieldProperty?.kind === "field" || + isFieldDefinedInIncludeContext( + fieldName, + viewName, + includeFieldContext, + includeViewNameAllowsAnyViewFallback + ); if (!isValidField) { const range = getRange(content, node); pushHtmlDiagnostic( @@ -353,7 +457,9 @@ function validateDom( baseScreenDocument, suppression, nextPanelViewContext, - currentDataFeedContext + currentDataFeedContext, + nextIncludeFieldContext, + includeFieldContextCache ); } }); @@ -444,6 +550,116 @@ function validateDom( return selectorViewName; } + function getViewNameFromIncludeCustomizationSelectors( + node: any, + context: IncludeFieldValidationContext + ): string | undefined { + const includeDocument = context.templateDocument; + if (!includeDocument) { + return undefined; + } + + let selectorViewName: string | undefined; + forEachCustomizationSelector(node, (_attributeName, _rawValue, normalizedValue) => { + if (selectorViewName) { + return; + } + + const { nodes, error } = queryBaseScreenElements(includeDocument, normalizedValue); + if (error || !nodes.length) { + return; + } + + for (const target of nodes) { + const candidateViewName = findParentViewName(target); + if (candidateViewName) { + selectorViewName = candidateViewName; + return; + } + } + }); + + return selectorViewName; + } + + function isFieldDefinedInIncludeContext( + fieldName: string | undefined, + viewName: string | undefined, + context: IncludeFieldValidationContext | undefined, + allowAnyViewFallback = false + ): boolean { + if (!context || !fieldName || hasIncludeTemplateExpression(fieldName)) { + return false; + } + + const normalizedViewName = viewName?.trim(); + if (normalizedViewName && !hasIncludeTemplateExpression(normalizedViewName)) { + const viewResolution = resolveIncludeView(normalizedViewName, context); + const fieldProperty = viewResolution?.viewClass?.properties.get(fieldName); + if (fieldProperty?.kind === "field") { + return true; + } + + return allowAnyViewFallback ? isFieldDefinedInAnyIncludeView(fieldName, context) : false; + } + + return isFieldDefinedInAnyIncludeView(fieldName, context); + } + + function resolveIncludeView( + viewName: string, + context: IncludeFieldValidationContext + ): ViewResolution | undefined { + if (context.viewResolutionCache.has(viewName)) { + return context.viewResolutionCache.get(viewName); + } + + const resolution = resolveViewBinding(viewName, context.screenClasses, context.classInfoMap); + context.viewResolutionCache.set(viewName, resolution); + return resolution; + } + + function isFieldDefinedInAnyIncludeView( + fieldName: string, + context: IncludeFieldValidationContext + ): boolean { + for (const screenClass of context.screenClasses) { + for (const property of screenClass.properties.values()) { + if (property.kind !== "view" && property.kind !== "viewCollection") { + continue; + } + + const viewClass = property.viewClassName + ? context.classInfoMap.get(property.viewClassName) + : undefined; + const fieldProperty = viewClass?.properties.get(fieldName); + if (fieldProperty?.kind === "field") { + return true; + } + } + } + + return false; + } + + function resolveIncludeTemplateValue( + value: string | undefined, + context: IncludeFieldValidationContext + ): string | undefined { + if (!value) { + return value; + } + + return value.replace(/{{\s*([^}\s]+)\s*}}/g, (match, parameterName: string) => { + const parameterValue = context.parameterValues.get(parameterName)?.trim(); + return parameterValue || match; + }).trim(); + } + + function hasIncludeTemplateExpression(value: string | undefined): boolean { + return typeof value === "string" && /{{\s*[^}]+\s*}}/.test(value); + } + function forEachCustomizationSelector( node: any, callback: (attributeName: string, rawValue: string, normalizedValue: string) => void From 03c5df94c2e9b98e0abe45a7293e96d2c204df99 Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Fri, 8 May 2026 13:17:21 +0200 Subject: [PATCH 02/21] include-aware qp-include definition navigation --- .../src/providers/html-definition-provider.ts | 272 +++++++++++++++--- .../TestIncludeFieldModificationHost.html | 2 +- .../src/test/suite/htmlProviders.test.ts | 31 ++ 3 files changed, 267 insertions(+), 38 deletions(-) diff --git a/acumate-plugin/src/providers/html-definition-provider.ts b/acumate-plugin/src/providers/html-definition-provider.ts index 7543de6..7b12d5e 100644 --- a/acumate-plugin/src/providers/html-definition-provider.ts +++ b/acumate-plugin/src/providers/html-definition-provider.ts @@ -27,8 +27,19 @@ import { queryBaseScreenElements, BaseScreenDocument, getCustomizationSelectorAttributes, + loadHtmlDocument, } from '../services/screen-html-service'; +interface DefinitionMetadataContext { + classInfoLookup: Map; + screenClasses: CollectedClassInfo[]; +} + +interface IncludeDefinitionContext extends DefinitionMetadataContext { + templateDocument?: BaseScreenDocument; + parameterValues: Map; +} + // Hooks VS Code so view/field bindings support "Go to Definition" directly from HTML. export function registerHtmlDefinitionProvider(context: vscode.ExtensionContext) { const provider = vscode.languages.registerDefinitionProvider( @@ -64,9 +75,10 @@ export class HtmlDefinitionProvider implements vscode.DefinitionProvider { } const baseScreenDocument = getBaseScreenDocument(document.uri.fsPath); + const workspaceRoots = vscode.workspace.workspaceFolders?.map(folder => folder.uri.fsPath); + const includeContext = getIncludeDefinitionContext(elementNode, document.uri.fsPath, 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,36 +87,33 @@ 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' && includeContext) { + const includeLocations = getFieldDefinitionLocations( + attributeContext.value, + elementNode, + includeContext, + includeContext.templateDocument, + includeContext.parameterValues, + true + ); + if (includeLocations.length) { + return includeLocations; } } @@ -125,6 +134,7 @@ export class HtmlDefinitionProvider implements vscode.DefinitionProvider { const classInfoLookup = createClassInfoLookup(classInfos); const screenClasses = filterScreenLikeClasses(relevantClassInfos); + const documentMetadataContext: DefinitionMetadataContext = { classInfoLookup, screenClasses }; // 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')) { @@ -184,30 +194,218 @@ 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, + baseScreenDocument + ); + if (locations.length) { + return locations; } + } - const resolution = resolveViewBinding(viewName, screenClasses, classInfoLookup); - const viewClass = resolution?.viewClass; - if (!viewClass) { - return; + return undefined; + } +} + +function getIncludeDefinitionContext( + elementNode: any, + documentPath: string, + workspaceRoots: string[] | undefined +): IncludeDefinitionContext | undefined { + const includeNode = findNearestIncludeNode(elementNode); + const includeUrl = includeNode?.attribs?.url; + if (typeof includeUrl !== 'string' || !includeUrl.length) { + return undefined; + } + + const includePath = resolveIncludeFilePath(includeUrl, documentPath, workspaceRoots); + if (!includePath) { + return undefined; + } + + const includeTsFilePaths = getRelatedTsFiles(includePath); + const classInfos = includeTsFilePaths.length ? loadClassInfosFromFiles(includeTsFilePaths) : []; + const relevantClassInfos = filterClassesBySource(classInfos, includeTsFilePaths); + + return { + classInfoLookup: createClassInfoLookup(classInfos), + screenClasses: filterScreenLikeClasses(relevantClassInfos), + templateDocument: loadHtmlDocument(includePath), + parameterValues: getIncludeParameterValues(includeNode), + }; +} + +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; +} + +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; +} + +function getSelectorLocations( + document: BaseScreenDocument | undefined, + selector: string +): vscode.Location[] { + if (!document) { + return []; + } + + const { nodes, error } = queryBaseScreenElements(document, selector); + if (error || !nodes.length) { + return []; + } + + 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(document, nodeCandidate); + if (location) { + locations.push(location); + } + } + + return locations; +} + +function getFieldDefinitionLocations( + rawFieldName: string | undefined, + elementNode: any, + metadataContext: DefinitionMetadataContext, + selectorDocument?: BaseScreenDocument, + parameterValues?: Map, + allowAnyViewFallback = false +): vscode.Location[] { + if (!rawFieldName) { + return []; + } + + const parsed = parseFieldName(rawFieldName); + let viewName = parsed.viewName; + let fieldName = parsed.fieldName; + let allowFallback = allowAnyViewFallback; + + if (!parsed.viewName) { + viewName = findParentViewName(elementNode); + } + if (!viewName) { + const selectorViewName = getViewNameFromCustomizationSelectors(elementNode, selectorDocument); + allowFallback ||= hasTemplateExpression(selectorViewName); + viewName = selectorViewName; + } + + if (parameterValues) { + allowFallback ||= hasTemplateExpression(viewName); + viewName = resolveTemplateValue(viewName, parameterValues); + fieldName = resolveTemplateValue(fieldName, parameterValues) ?? fieldName; + } + + if (!fieldName) { + return []; + } + + if (viewName && !hasTemplateExpression(viewName)) { + const resolution = resolveViewBinding(viewName, metadataContext.screenClasses, metadataContext.classInfoLookup); + const fieldProperty = resolution?.viewClass?.properties.get(fieldName); + if (fieldProperty?.kind === 'field') { + return [createLocationFromProperty(fieldProperty)]; + } + + if (!allowFallback) { + return []; + } + } + + if (!allowFallback) { + return []; + } + + return getFieldDefinitionsFromAnyView(fieldName, metadataContext); +} + +function parseFieldName(rawFieldName: string): { viewName?: string; fieldName: string } { + 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 }; +} + +function getFieldDefinitionsFromAnyView( + fieldName: string, + metadataContext: DefinitionMetadataContext +): vscode.Location[] { + const locations: vscode.Location[] = []; + const seen = new Set(); + + for (const screenClass of metadataContext.screenClasses) { + for (const property of screenClass.properties.values()) { + if (property.kind !== 'view' && property.kind !== 'viewCollection') { + continue; } - const fieldProperty = viewClass.properties.get(attributeContext.value); - if (!fieldProperty || fieldProperty.kind !== 'field') { - return; + const viewClass = property.viewClassName + ? metadataContext.classInfoLookup.get(property.viewClassName) + : undefined; + const fieldProperty = viewClass?.properties.get(fieldName); + if (fieldProperty?.kind !== 'field') { + continue; } - return createLocationFromProperty(fieldProperty); + const key = `${fieldProperty.sourceFile.fileName}:${fieldProperty.node.getStart()}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + locations.push(createLocationFromProperty(fieldProperty)); } + } - return undefined; + return locations; +} + +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(); +} + +function hasTemplateExpression(value: string | undefined): boolean { + return typeof value === 'string' && /{{\s*[^}]+\s*}}/.test(value); } function findActionProperty(actionName: string | undefined, screenClasses: CollectedClassInfo[]): ClassPropertyInfo | undefined { diff --git a/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHost.html b/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHost.html index 59e5e9b..5634cff 100644 --- a/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHost.html +++ b/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHost.html @@ -1,6 +1,6 @@ - + diff --git a/acumate-plugin/src/test/suite/htmlProviders.test.ts b/acumate-plugin/src/test/suite/htmlProviders.test.ts index 5992626..e948c7e 100644 --- a/acumate-plugin/src/test/suite/htmlProviders.test.ts +++ b/acumate-plugin/src/test/suite/htmlProviders.test.ts @@ -19,6 +19,7 @@ const qpTemplateFixturePath = path.join(fixturesRoot, 'TestQpTemplate.html'); const qpTemplateRecordInsidePath = path.join(fixturesRoot, 'TestQpTemplateRecordInside.html'); const qpTemplateRecordOutsidePath = path.join(fixturesRoot, 'TestQpTemplateRecordOutside.html'); const includeHostPath = path.join(fixturesRoot, 'TestIncludeHost.html'); +const includeFieldModificationHostPath = path.join(fixturesRoot, 'TestIncludeFieldModificationHost.html'); const importedFixturePath = path.join(fixturesRoot, 'TestScreenImported.html'); const configCompletionPath = path.join(fixturesRoot, 'TestConfigBindingCompletion.html'); const controlTypeCompletionPath = path.join(fixturesRoot, 'TestControlTypeCompletion.html'); @@ -567,6 +568,36 @@ describe('HTML definition provider integration', () => { ); }); + it('navigates from qp-include child selector attributes to include template HTML', async () => { + const document = await vscode.workspace.openTextDocument(includeFieldModificationHostPath); + const provider = new HtmlDefinitionProvider(); + const caret = positionAt( + document, + "before=\"#includedView [name='Anchor']\"", + "before=\"".length + 1 + ); + const definition = await provider.provideDefinition(document, caret); + const locations = Array.isArray(definition) ? definition : definition ? [definition] : []; + assert.ok(locations.length >= 1, 'No definitions returned for qp-include customization selector'); + assert.ok( + locations.some(loc => loc.uri.fsPath.endsWith('include-field-modification-template.html')), + 'Expected navigation to include template HTML' + ); + }); + + it('navigates from qp-include child field name to include template TS definition', async () => { + const document = await vscode.workspace.openTextDocument(includeFieldModificationHostPath); + const provider = new HtmlDefinitionProvider(); + const caret = positionAt(document, 'name="IncludedAlpha"', 'name="'.length + 1); + const definition = await provider.provideDefinition(document, caret); + const locations = Array.isArray(definition) ? definition : definition ? [definition] : []; + assert.ok(locations.length >= 1, 'No definitions returned for qp-include child field name'); + assert.ok( + locations.some(loc => loc.uri.fsPath.endsWith('include-field-modification-template.ts')), + 'Expected navigation to include template TS file' + ); + }); + it('navigates from selector-injected field name to TS definition', async () => { const document = await vscode.workspace.openTextDocument(screenSelectorHtmlPath); const provider = new HtmlDefinitionProvider(); From 0ef11624eef258f7a80ab096df954b3a09a33dbf Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Fri, 8 May 2026 13:25:07 +0200 Subject: [PATCH 03/21] support remove selector navigation --- .../src/services/screen-html-service.ts | 2 +- .../html/TestIncludeFieldModificationHost.html | 1 + .../src/test/suite/htmlProviders.test.ts | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/acumate-plugin/src/services/screen-html-service.ts b/acumate-plugin/src/services/screen-html-service.ts index 75d3b07..b6c1f01 100644 --- a/acumate-plugin/src/services/screen-html-service.ts +++ b/acumate-plugin/src/services/screen-html-service.ts @@ -14,7 +14,7 @@ export interface SelectorQueryResult { error?: string; } -const customizationSelectorAttributes = ["before", "after", "append", "prepend", "prepand", "move"] as const; +const customizationSelectorAttributes = ["before", "after", "append", "prepend", "prepand", "move", "remove"] as const; const customizationSelectorAttributeSet = new Set( customizationSelectorAttributes.map(attribute => attribute.toLowerCase()) ); diff --git a/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHost.html b/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHost.html index 5634cff..3051450 100644 --- a/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHost.html +++ b/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHost.html @@ -1,6 +1,7 @@ + diff --git a/acumate-plugin/src/test/suite/htmlProviders.test.ts b/acumate-plugin/src/test/suite/htmlProviders.test.ts index e948c7e..35c6fd3 100644 --- a/acumate-plugin/src/test/suite/htmlProviders.test.ts +++ b/acumate-plugin/src/test/suite/htmlProviders.test.ts @@ -585,6 +585,23 @@ describe('HTML definition provider integration', () => { ); }); + it('navigates from qp-include child remove selectors to include template HTML', async () => { + const document = await vscode.workspace.openTextDocument(includeFieldModificationHostPath); + const provider = new HtmlDefinitionProvider(); + const caret = positionAt( + document, + "remove=\"#includedView [name='Anchor']\"", + "remove=\"".length + 1 + ); + const definition = await provider.provideDefinition(document, caret); + const locations = Array.isArray(definition) ? definition : definition ? [definition] : []; + assert.ok(locations.length >= 1, 'No definitions returned for qp-include remove selector'); + assert.ok( + locations.some(loc => loc.uri.fsPath.endsWith('include-field-modification-template.html')), + 'Expected navigation to include template HTML' + ); + }); + it('navigates from qp-include child field name to include template TS definition', async () => { const document = await vscode.workspace.openTextDocument(includeFieldModificationHostPath); const provider = new HtmlDefinitionProvider(); From f9a587e4329cacf1afa72423b50343ceb296f702 Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Mon, 11 May 2026 17:58:00 +0200 Subject: [PATCH 04/21] Resolve selectors against extension dependencies --- .../src/providers/html-definition-provider.ts | 17 +- .../src/services/screen-html-service.ts | 360 +++++++++++++++++- .../extensions/SO301000_Approvals.html | 3 + .../SO301000/extensions/SO301000_Approvals.ts | 5 + .../extensions/SO301000_Discounts.html | 5 + .../SO301000/extensions/SO301000_Discounts.ts | 12 + .../src/test/suite/htmlProviders.test.ts | 24 ++ .../src/test/suite/htmlValidation.test.ts | 17 + .../htmlValidation/html-validation.ts | 3 +- 9 files changed, 436 insertions(+), 10 deletions(-) create mode 100644 acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Approvals.html create mode 100644 acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Approvals.ts create mode 100644 acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Discounts.html create mode 100644 acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Discounts.ts diff --git a/acumate-plugin/src/providers/html-definition-provider.ts b/acumate-plugin/src/providers/html-definition-provider.ts index 7b12d5e..07f24b5 100644 --- a/acumate-plugin/src/providers/html-definition-provider.ts +++ b/acumate-plugin/src/providers/html-definition-provider.ts @@ -28,6 +28,7 @@ import { BaseScreenDocument, getCustomizationSelectorAttributes, loadHtmlDocument, + getDocumentForNode, } from '../services/screen-html-service'; interface DefinitionMetadataContext { @@ -268,20 +269,24 @@ function getSelectorLocations( return []; } - const { nodes, error } = queryBaseScreenElements(document, selector); + const { nodes, matches, error } = queryBaseScreenElements(document, selector); if (error || !nodes.length) { return []; } const locations: vscode.Location[] = []; - const seen = new Set(); - for (const nodeCandidate of nodes) { + 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; - if (startIndex === undefined || seen.has(startIndex)) { + const key = startIndex === undefined ? undefined : `${sourceDocument.filePath}:${startIndex}`; + if (key === undefined || seen.has(key)) { continue; } - seen.add(startIndex); - const location = createLocationFromHtmlNode(document, nodeCandidate); + seen.add(key); + const location = createLocationFromHtmlNode(sourceDocument, nodeCandidate); if (location) { locations.push(location); } diff --git a/acumate-plugin/src/services/screen-html-service.ts b/acumate-plugin/src/services/screen-html-service.ts index b6c1f01..ba3e6b2 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,10 +8,18 @@ export interface BaseScreenDocument { filePath: string; content: string; dom: any[]; + sourceDocuments?: readonly BaseScreenDocument[]; + nodeDocumentMap?: WeakMap; +} + +export interface SelectorQueryMatch { + node: any; + document: BaseScreenDocument; } export interface SelectorQueryResult { nodes: any[]; + matches: SelectorQueryMatch[]; error?: string; } @@ -40,7 +49,17 @@ export function getBaseScreenDocument(htmlFilePath: string): BaseScreenDocument return undefined; } - return loadHtmlDocument(baseHtmlPath); + const baseDocument = loadHtmlDocument(baseHtmlPath); + if (!baseDocument) { + return undefined; + } + + const dependencyDocuments = getExtensionDependencyHtmlDocuments(htmlFilePath); + if (!dependencyDocuments.length) { + return baseDocument; + } + + return createCompositeScreenDocument([baseDocument, ...dependencyDocuments]); } export function loadHtmlDocument(htmlFilePath: string): BaseScreenDocument | undefined { @@ -95,21 +114,49 @@ export function resolveBaseScreenHtmlPath(htmlFilePath: string): string | undefi export function queryBaseScreenElements(document: BaseScreenDocument, selector: string): SelectorQueryResult { const normalized = selector?.trim(); if (!normalized) { - return { nodes: [] }; + return { nodes: [], matches: [] }; } try { const nodes = selectAll(normalized, document.dom); - return { nodes }; + const matches = nodes.map(node => ({ + node, + document: getDocumentForNode(document, node), + })); + return { nodes, matches }; } catch (error) { return { nodes: [], + matches: [], error: error instanceof Error ? error.message : "Unknown selector error", }; } } +export function getDocumentForNode( + document: BaseScreenDocument, + node: any +): BaseScreenDocument { + if (node && typeof node === "object") { + const sourceDocument = document.nodeDocumentMap?.get(node); + if (sourceDocument) { + return sourceDocument; + } + } + + return document; +} + +export function getScreenDocumentDisplayName(document: BaseScreenDocument): string { + const sourceDocuments = document.sourceDocuments; + if (!sourceDocuments || sourceDocuments.length <= 1) { + return path.basename(document.filePath); + } + + return `${path.basename(sourceDocuments[0].filePath)} and its extension dependencies`; +} + function parseHtml(content: string): any[] { let domTree: any[] = []; const handler = new DomHandler( @@ -134,3 +181,310 @@ function tryGetMtime(targetPath: string): number | undefined { return undefined; } } + +function createCompositeScreenDocument(documents: BaseScreenDocument[]): BaseScreenDocument { + const nodeDocumentMap = new WeakMap(); + for (const document of documents) { + mapNodeDocuments(document.dom, document, nodeDocumentMap); + } + + return { + filePath: documents[0].filePath, + content: documents[0].content, + dom: documents.flatMap(document => document.dom), + sourceDocuments: documents, + nodeDocumentMap, + }; +} + +function mapNodeDocuments( + nodes: any[] | undefined, + document: BaseScreenDocument, + nodeDocumentMap: WeakMap +) { + if (!nodes) { + return; + } + + for (const node of nodes) { + if (node && typeof node === "object") { + nodeDocumentMap.set(node, document); + mapNodeDocuments(node.children, document, nodeDocumentMap); + } + } +} + +function getExtensionDependencyHtmlDocuments(htmlFilePath: string): BaseScreenDocument[] { + const extensionDirectory = getExtensionDirectory(htmlFilePath); + if (!extensionDirectory) { + return []; + } + + const tsFilePath = getCorrespondingTsFilePath(htmlFilePath); + if (!tsFilePath) { + return []; + } + + const documents: BaseScreenDocument[] = []; + const emittedHtmlPaths = new Set(); + const activeTsPaths = new Set(); + const rootInterfaceName = getExtensionNameFromFilePath(htmlFilePath); + + collectDependencyHtmlDocuments( + tsFilePath, + rootInterfaceName, + extensionDirectory, + activeTsPaths, + emittedHtmlPaths, + documents + ); + + return documents; +} + +function collectDependencyHtmlDocuments( + tsFilePath: string, + interfaceName: string, + extensionDirectory: string, + activeTsPaths: Set, + emittedHtmlPaths: Set, + documents: BaseScreenDocument[] +) { + const normalizedTsPath = path.normalize(tsFilePath); + if (activeTsPaths.has(normalizedTsPath)) { + return; + } + + activeTsPaths.add(normalizedTsPath); + + for (const dependency of getInterfaceDependencies(normalizedTsPath, interfaceName)) { + const dependencyTsPath = resolveDependencyTsPath(normalizedTsPath, dependency); + const dependencyHtmlPath = resolveDependencyHtmlPath( + normalizedTsPath, + dependency, + extensionDirectory, + dependencyTsPath + ); + const dependencyInterfaceName = dependencyTsPath + ? getExtensionNameFromFilePath(dependencyTsPath) + : dependency.typeName; + + if (dependencyTsPath) { + collectDependencyHtmlDocuments( + dependencyTsPath, + dependencyInterfaceName, + extensionDirectory, + activeTsPaths, + emittedHtmlPaths, + documents + ); + } + + if (!dependencyHtmlPath || !isHtmlPathInDirectory(dependencyHtmlPath, extensionDirectory)) { + continue; + } + + const normalizedHtmlPath = path.normalize(dependencyHtmlPath); + if (emittedHtmlPaths.has(normalizedHtmlPath)) { + continue; + } + + const dependencyDocument = loadHtmlDocument(normalizedHtmlPath); + if (!dependencyDocument) { + continue; + } + + emittedHtmlPaths.add(normalizedHtmlPath); + documents.push(dependencyDocument); + } + + activeTsPaths.delete(normalizedTsPath); +} + +interface InterfaceDependency { + typeName: string; + moduleSpecifier?: string; +} + +function getInterfaceDependencies( + tsFilePath: string, + interfaceName: string +): InterfaceDependency[] { + const sourceFile = tryReadTsSourceFile(tsFilePath); + if (!sourceFile) { + return []; + } + + const importMap = getImportMap(sourceFile); + const dependencies: InterfaceDependency[] = []; + + const visit = (node: ts.Node) => { + if (ts.isInterfaceDeclaration(node) && node.name.text === interfaceName) { + for (const clause of node.heritageClauses ?? []) { + if (clause.token !== ts.SyntaxKind.ExtendsKeyword) { + continue; + } + + for (const heritageType of clause.types) { + const typeName = getHeritageIdentifierName(heritageType); + if (!typeName) { + continue; + } + + dependencies.push({ + typeName, + moduleSpecifier: importMap.get(typeName), + }); + } + } + return; + } + + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return dependencies; +} + +function getImportMap(sourceFile: ts.SourceFile): Map { + const imports = new Map(); + + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement) || !statement.importClause || !ts.isStringLiteral(statement.moduleSpecifier)) { + continue; + } + + const moduleSpecifier = statement.moduleSpecifier.text; + const importClause = statement.importClause; + + if (importClause.name) { + imports.set(importClause.name.text, moduleSpecifier); + } + + const namedBindings = importClause.namedBindings; + if (namedBindings && ts.isNamedImports(namedBindings)) { + for (const element of namedBindings.elements) { + imports.set(element.name.text, moduleSpecifier); + } + } + } + + return imports; +} + +function getHeritageIdentifierName(heritageType: ts.ExpressionWithTypeArguments): string | undefined { + const expression = heritageType.expression; + return ts.isIdentifier(expression) ? expression.text : undefined; +} + +function resolveDependencyTsPath( + sourceTsPath: string, + dependency: InterfaceDependency +): string | undefined { + if (dependency.moduleSpecifier?.startsWith(".")) { + return resolveModuleTsPath(sourceTsPath, dependency.moduleSpecifier); + } + + return findFileWithExtensions(path.join(path.dirname(sourceTsPath), dependency.typeName), [".ts", ".tsx"]); +} + +function resolveDependencyHtmlPath( + sourceTsPath: string, + dependency: InterfaceDependency, + extensionDirectory: string, + dependencyTsPath: string | undefined +): string | undefined { + if (dependencyTsPath) { + const fromTsPath = findHtmlNextToTsFile(dependencyTsPath); + if (fromTsPath) { + return fromTsPath; + } + } + + if (dependency.moduleSpecifier?.startsWith(".")) { + const fromModulePath = findFileWithExtensions( + path.resolve(path.dirname(sourceTsPath), dependency.moduleSpecifier), + [".html", ".htm"] + ); + if (fromModulePath) { + return fromModulePath; + } + } + + return findFileWithExtensions(path.join(extensionDirectory, dependency.typeName), [".html", ".htm"]); +} + +function resolveModuleTsPath(sourceTsPath: string, moduleSpecifier: string): string | undefined { + const basePath = path.resolve(path.dirname(sourceTsPath), moduleSpecifier); + return findFileWithExtensions(basePath, [".ts", ".tsx", ".d.ts"]); +} + +function findHtmlNextToTsFile(tsFilePath: string): string | undefined { + const parsedPath = path.parse(tsFilePath); + return findFileWithExtensions(path.join(parsedPath.dir, parsedPath.name), [".html", ".htm"]); +} + +function getCorrespondingTsFilePath(htmlFilePath: string): string | undefined { + const normalized = path.normalize(htmlFilePath); + const directCandidate = normalized.replace(/\.html?$/i, ".ts"); + if (fs.existsSync(directCandidate)) { + return directCandidate; + } + + const withoutHtml = normalized.replace(/\.html?$/i, ""); + const trimmedBase = withoutHtml.replace(/\.+$/, ""); + if (trimmedBase && trimmedBase !== withoutHtml) { + const trimmedCandidate = `${trimmedBase}.ts`; + if (fs.existsSync(trimmedCandidate)) { + return trimmedCandidate; + } + } + + return undefined; +} + +function findFileWithExtensions(basePath: string, extensions: string[]): string | undefined { + for (const extension of extensions) { + const candidate = `${basePath}${extension}`; + if (fs.existsSync(candidate)) { + return candidate; + } + } + + return undefined; +} + +function getExtensionDirectory(htmlFilePath: string): string | undefined { + const normalized = path.normalize(htmlFilePath); + const lower = normalized.toLowerCase(); + const marker = `${path.sep}extensions${path.sep}`.toLowerCase(); + const markerIndex = lower.lastIndexOf(marker); + if (markerIndex === -1) { + return undefined; + } + + return normalized.substring(0, markerIndex + marker.length - 1); +} + +function getExtensionNameFromFilePath(filePath: string): string { + const parsedPath = path.parse(filePath); + return parsedPath.name.replace(/\.+$/, ""); +} + +function isHtmlPathInDirectory(htmlFilePath: string, directoryPath: string): boolean { + const normalizedHtmlDirectory = path.normalize(path.dirname(htmlFilePath)).toLowerCase(); + const normalizedDirectory = path.normalize(directoryPath).toLowerCase(); + return normalizedHtmlDirectory === normalizedDirectory; +} + +function tryReadTsSourceFile(tsFilePath: string): ts.SourceFile | undefined { + const normalized = path.normalize(tsFilePath); + try { + const content = fs.readFileSync(normalized, "utf-8"); + return ts.createSourceFile(normalized, content, ts.ScriptTarget.Latest, true); + } + catch { + return undefined; + } +} diff --git a/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Approvals.html b/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Approvals.html new file mode 100644 index 0000000..a7f9be4 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Approvals.html @@ -0,0 +1,3 @@ + diff --git a/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Approvals.ts b/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Approvals.ts new file mode 100644 index 0000000..d1e1521 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Approvals.ts @@ -0,0 +1,5 @@ +import { SO301000 } from "../SO301000"; + +export interface SO301000_Approvals extends SO301000 {} + +export class SO301000_Approvals {} diff --git a/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Discounts.html b/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Discounts.html new file mode 100644 index 0000000..a92e699 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Discounts.html @@ -0,0 +1,5 @@ + diff --git a/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Discounts.ts b/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Discounts.ts new file mode 100644 index 0000000..52eb4eb --- /dev/null +++ b/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Discounts.ts @@ -0,0 +1,12 @@ +import { SO301000 } from "../SO301000"; +import { SO301000_Approvals } from "./SO301000_Approvals"; + +export interface SO301000_Discounts extends SO301000, SO301000_Approvals {} + +export class SO301000_Discounts { + DiscountDetails = createCollection(SODiscountDetails); +} + +export class SODiscountDetails extends PXView { + DiscountID!: PXFieldState; +} diff --git a/acumate-plugin/src/test/suite/htmlProviders.test.ts b/acumate-plugin/src/test/suite/htmlProviders.test.ts index 35c6fd3..a0592f7 100644 --- a/acumate-plugin/src/test/suite/htmlProviders.test.ts +++ b/acumate-plugin/src/test/suite/htmlProviders.test.ts @@ -52,6 +52,13 @@ const screenSelectorHtmlPath = path.join( 'extensions', 'SO301000_FieldSelectors.html' ); +const screenDependentExtensionHtmlPath = path.join( + screenFixturesRoot, + 'SO', + 'SO301000', + 'extensions', + 'SO301000_Discounts.html' +); const fieldControlHtmlPath = path.join(fixturesRoot, 'FieldControlInfo.html'); const backendGraphName = 'PX.SM.ProjectNewUiFrontendFileMaintenance'; @@ -568,6 +575,23 @@ describe('HTML definition provider integration', () => { ); }); + it('navigates from customization selector attributes to dependent extension HTML', async () => { + const document = await vscode.workspace.openTextDocument(screenDependentExtensionHtmlPath); + const provider = new HtmlDefinitionProvider(); + const caret = positionAt( + document, + 'after="#tab-Approval"', + 'after="'.length + 1 + ); + const definition = await provider.provideDefinition(document, caret); + const locations = Array.isArray(definition) ? definition : definition ? [definition] : []; + assert.ok(locations.length >= 1, 'No definitions returned for dependent extension selector'); + assert.ok( + locations.some(loc => loc.uri.fsPath.endsWith('SO301000_Approvals.html')), + 'Expected navigation to dependent extension HTML' + ); + }); + it('navigates from qp-include child selector attributes to include template HTML', async () => { const document = await vscode.workspace.openTextDocument(includeFieldModificationHostPath); const provider = new HtmlDefinitionProvider(); diff --git a/acumate-plugin/src/test/suite/htmlValidation.test.ts b/acumate-plugin/src/test/suite/htmlValidation.test.ts index 25e26aa..3b1f46a 100644 --- a/acumate-plugin/src/test/suite/htmlValidation.test.ts +++ b/acumate-plugin/src/test/suite/htmlValidation.test.ts @@ -22,6 +22,13 @@ const screenSelectorExtensionFixture = path.join( 'extensions', 'SO301000_FieldSelectors.html' ); +const screenDependentExtensionFixture = path.join( + screenFixturesRoot, + 'SO', + 'SO301000', + 'extensions', + 'SO301000_Discounts.html' +); async function openFixtureDocument(fileName: string) { const fullPath = path.join(fixturesRoot, fileName); @@ -401,6 +408,16 @@ describe('HTML validation diagnostics', () => { ); }); + it('resolves customization selectors against dependent extension HTML', async () => { + const document = await vscode.workspace.openTextDocument(screenDependentExtensionFixture); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.ok( + !diagnostics.some(d => d.message.includes('after selector') && d.message.includes('tab-Approval')), + 'Expected selector targeting a dependent extension element to be valid' + ); + }); + it('derives view metadata from selector targets when lacks a parent view', async () => { const document = await vscode.workspace.openTextDocument(screenSelectorExtensionFixture); await validateHtmlFile(document); diff --git a/acumate-plugin/src/validation/htmlValidation/html-validation.ts b/acumate-plugin/src/validation/htmlValidation/html-validation.ts index 7581c67..3c123ee 100644 --- a/acumate-plugin/src/validation/htmlValidation/html-validation.ts +++ b/acumate-plugin/src/validation/htmlValidation/html-validation.ts @@ -26,6 +26,7 @@ import { BaseScreenDocument, getCustomizationSelectorAttributes, loadHtmlDocument, + getScreenDocumentDisplayName, } from "../../services/screen-html-service"; import { createSuppressionEngine, SuppressionEngine } from "../../diagnostics/suppression"; @@ -511,7 +512,7 @@ function validateDom( } if (!nodes.length) { - const baseName = path.basename(baseScreenDocument.filePath); + const baseName = getScreenDocumentDisplayName(baseScreenDocument); pushHtmlDiagnostic( diagnostics, suppression, From 0b32444c3c30f50495946eedd1cb5cddbbfe0c34 Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Tue, 12 May 2026 15:12:18 +0200 Subject: [PATCH 05/21] Resolve include selector target view scopes --- .../src/providers/html-definition-provider.ts | 6 ++- acumate-plugin/src/providers/html-shared.ts | 47 ++++++++++++------- .../TestIncludeFieldModificationHost.html | 1 + .../include-field-modification-template.html | 3 ++ .../src/test/suite/htmlProviders.test.ts | 13 +++++ .../src/test/suite/htmlShared.test.ts | 12 +++++ .../htmlValidation/html-validation.ts | 6 +-- 7 files changed, 66 insertions(+), 22 deletions(-) diff --git a/acumate-plugin/src/providers/html-definition-provider.ts b/acumate-plugin/src/providers/html-definition-provider.ts index 07f24b5..9b1f612 100644 --- a/acumate-plugin/src/providers/html-definition-provider.ts +++ b/acumate-plugin/src/providers/html-definition-provider.ts @@ -19,6 +19,7 @@ import { elevateToElementNode, getAttributeContext, findParentViewName, + findViewNameAtOrAbove, } from './html-shared'; import { resolveIncludeFilePath } from '../services/include-service'; import { @@ -199,7 +200,8 @@ export class HtmlDefinitionProvider implements vscode.DefinitionProvider { attributeContext.value, elementNode, documentMetadataContext, - baseScreenDocument + includeContext?.templateDocument ?? baseScreenDocument, + includeContext?.parameterValues ); if (locations.length) { return locations; @@ -482,7 +484,7 @@ function getViewNameFromCustomizationSelectors( } for (const candidate of nodes) { - const viewName = findParentViewName(candidate); + const viewName = findViewNameAtOrAbove(candidate); if (viewName) { return viewName; } diff --git a/acumate-plugin/src/providers/html-shared.ts b/acumate-plugin/src/providers/html-shared.ts index f679383..f235679 100644 --- a/acumate-plugin/src/providers/html-shared.ts +++ b/acumate-plugin/src/providers/html-shared.ts @@ -234,30 +234,43 @@ 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); +} diff --git a/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHost.html b/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHost.html index 3051450..0c5932b 100644 --- a/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHost.html +++ b/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHost.html @@ -2,6 +2,7 @@ + diff --git a/acumate-plugin/src/test/fixtures/includes/include-field-modification-template.html b/acumate-plugin/src/test/fixtures/includes/include-field-modification-template.html index ecc296b..78e8fa6 100644 --- a/acumate-plugin/src/test/fixtures/includes/include-field-modification-template.html +++ b/acumate-plugin/src/test/fixtures/includes/include-field-modification-template.html @@ -2,4 +2,7 @@ + + + diff --git a/acumate-plugin/src/test/suite/htmlProviders.test.ts b/acumate-plugin/src/test/suite/htmlProviders.test.ts index a0592f7..b9ec2e5 100644 --- a/acumate-plugin/src/test/suite/htmlProviders.test.ts +++ b/acumate-plugin/src/test/suite/htmlProviders.test.ts @@ -639,6 +639,19 @@ describe('HTML definition provider integration', () => { ); }); + it('navigates from qp-include child field name to host TS definition via include selector target', async () => { + const document = await vscode.workspace.openTextDocument(includeFieldModificationHostPath); + const provider = new HtmlDefinitionProvider(); + const caret = positionAt(document, 'name="HostField"', 'name="'.length + 1); + const definition = await provider.provideDefinition(document, caret); + const locations = Array.isArray(definition) ? definition : definition ? [definition] : []; + assert.ok(locations.length >= 1, 'No definitions returned for host field inside qp-include'); + assert.ok( + locations.some(loc => loc.uri.fsPath.endsWith('TestIncludeFieldModificationHost.ts')), + 'Expected navigation to host screen TS file' + ); + }); + it('navigates from selector-injected field name to TS definition', async () => { const document = await vscode.workspace.openTextDocument(screenSelectorHtmlPath); const provider = new HtmlDefinitionProvider(); diff --git a/acumate-plugin/src/test/suite/htmlShared.test.ts b/acumate-plugin/src/test/suite/htmlShared.test.ts index 6a5d0f8..8bac445 100644 --- a/acumate-plugin/src/test/suite/htmlShared.test.ts +++ b/acumate-plugin/src/test/suite/htmlShared.test.ts @@ -8,6 +8,7 @@ import { getAttributeContext, readAttributeAtOffset, findParentViewName, + findViewNameAtOrAbove, } from '../../providers/html-shared'; function extractOffset(template: string): { text: string; offset: number } { @@ -90,4 +91,15 @@ describe('findParentViewName', () => { const viewName = findParentViewName(element); assert.strictEqual(viewName, 'CurrentDocument'); }); + + it('returns the current node view scope for selector targets', async () => { + const { text, offset } = extractOffset(''); + await vscode.workspace.openTextDocument({ language: 'html', content: text }); + const dom = parseDocumentDom(text); + assert.ok(dom); + const node = findNodeAtOffset(dom!, offset); + const element = elevateToElementNode(node); + const viewName = findViewNameAtOrAbove(element); + assert.strictEqual(viewName, 'HeaderView'); + }); }); diff --git a/acumate-plugin/src/validation/htmlValidation/html-validation.ts b/acumate-plugin/src/validation/htmlValidation/html-validation.ts index 3c123ee..af495c0 100644 --- a/acumate-plugin/src/validation/htmlValidation/html-validation.ts +++ b/acumate-plugin/src/validation/htmlValidation/html-validation.ts @@ -14,7 +14,7 @@ import { parseConfigObject, filterClassesBySource, } from "../../utils"; -import { findParentViewName } from "../../providers/html-shared"; +import { findParentViewName, findViewNameAtOrAbove } from "../../providers/html-shared"; import { getIncludeMetadata, resolveIncludeFilePath } from "../../services/include-service"; import { getScreenTemplates } from "../../services/screen-template-service"; import { getClientControlsMetadata, ClientControlMetadata } from "../../services/client-controls-service"; @@ -540,7 +540,7 @@ function validateDom( } for (const target of nodes) { - const candidateViewName = findParentViewName(target); + const candidateViewName = findViewNameAtOrAbove(target); if (candidateViewName) { selectorViewName = candidateViewName; return; @@ -572,7 +572,7 @@ function validateDom( } for (const target of nodes) { - const candidateViewName = findParentViewName(target); + const candidateViewName = findViewNameAtOrAbove(target); if (candidateViewName) { selectorViewName = candidateViewName; return; From f837aece5343597a44ae10ed1b110861b419c009 Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Tue, 12 May 2026 15:42:27 +0200 Subject: [PATCH 06/21] Honor inherited control config properties --- .../src/services/client-controls-service.ts | 100 +++++++++++++++++- .../fixtures/html/TestConfigBindingValid.html | 4 +- .../test/suite/clientControlsService.test.ts | 8 ++ .../src/test/suite/htmlProviders.test.ts | 2 + .../htmlValidation/html-validation.ts | 12 ++- 5 files changed, 118 insertions(+), 8 deletions(-) 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/test/fixtures/html/TestConfigBindingValid.html b/acumate-plugin/src/test/fixtures/html/TestConfigBindingValid.html index 618df0a..bab88e0 100644 --- a/acumate-plugin/src/test/fixtures/html/TestConfigBindingValid.html +++ b/acumate-plugin/src/test/fixtures/html/TestConfigBindingValid.html @@ -1,3 +1,3 @@ - + diff --git a/acumate-plugin/src/test/suite/clientControlsService.test.ts b/acumate-plugin/src/test/suite/clientControlsService.test.ts index a7f3323..3677d1b 100644 --- a/acumate-plugin/src/test/suite/clientControlsService.test.ts +++ b/acumate-plugin/src/test/suite/clientControlsService.test.ts @@ -42,5 +42,13 @@ function assertBarcodeControl(controls: ReturnType prop.name) ?? []; assert.ok(propNames.includes('soundControl')); assert.ok(propNames.includes('soundPath')); + + const button = controls.find(control => control.tagName === 'qp-button'); + assert.ok(button, 'Expected qp-button control to be discovered'); + assert.strictEqual(button?.config?.typeName, 'IButtonControlConfig'); + const buttonProps = button?.config?.definition?.properties.map(prop => prop.name) ?? []; + assert.ok(buttonProps.includes('id'), 'Expected inherited id config property'); + assert.ok(buttonProps.includes('tabIndex'), 'Expected inherited tabIndex config property'); + assert.ok(buttonProps.includes('text'), 'Expected own text config property'); } diff --git a/acumate-plugin/src/test/suite/htmlProviders.test.ts b/acumate-plugin/src/test/suite/htmlProviders.test.ts index b9ec2e5..ad0acfa 100644 --- a/acumate-plugin/src/test/suite/htmlProviders.test.ts +++ b/acumate-plugin/src/test/suite/htmlProviders.test.ts @@ -377,6 +377,8 @@ describe('HTML completion provider integration', () => { const completions = await provider.provideCompletionItems(document, caret); assert.ok(completions && completions.length > 0, 'No completions returned for config.bind'); const labels = completions.map(item => item.label); + assert.ok(labels.includes('id'), 'id config property not suggested'); + assert.ok(labels.includes('tabIndex'), 'tabIndex config property not suggested'); assert.ok(labels.includes('enabled'), 'enabled config property not suggested'); assert.ok(labels.includes('dialogResult'), 'dialogResult config property not suggested'); }); diff --git a/acumate-plugin/src/validation/htmlValidation/html-validation.ts b/acumate-plugin/src/validation/htmlValidation/html-validation.ts index af495c0..e11353d 100644 --- a/acumate-plugin/src/validation/htmlValidation/html-validation.ts +++ b/acumate-plugin/src/validation/htmlValidation/html-validation.ts @@ -257,7 +257,7 @@ function validateDom( const requiresIdAttribute = normalizedTagName === "qp-panel" || (normalizedTagName && controlMetadata.has(normalizedTagName) && !idOptionalTags.has(normalizedTagName)); - if (requiresIdAttribute && !elementId.length) { + if (requiresIdAttribute && !elementId.length && !hasConfigId(node)) { const range = getRange(content, node); const message = normalizedTagName === "qp-panel" @@ -811,6 +811,16 @@ function validateDom( ); } } + + function hasConfigId(node: any): boolean { + const rawConfig = node.attribs?.["config.bind"]; + if (typeof rawConfig !== "string" || !rawConfig.length) { + return false; + } + + const configObject = parseConfigObject(rawConfig); + return Boolean(configObject && Object.prototype.hasOwnProperty.call(configObject, "id")); + } } function validateIncludeNode( From deb617e91337b4bfac5e152d88e891bdeb42a993 Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Tue, 12 May 2026 15:48:00 +0200 Subject: [PATCH 07/21] Allow id-less shell qp controls --- .../src/test/fixtures/html/TestControlIdOptional.html | 3 +++ acumate-plugin/src/test/suite/htmlValidation.test.ts | 2 +- .../src/validation/htmlValidation/html-validation.ts | 9 ++++++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/acumate-plugin/src/test/fixtures/html/TestControlIdOptional.html b/acumate-plugin/src/test/fixtures/html/TestControlIdOptional.html index 77c15ab..cbf899c 100644 --- a/acumate-plugin/src/test/fixtures/html/TestControlIdOptional.html +++ b/acumate-plugin/src/test/fixtures/html/TestControlIdOptional.html @@ -1,4 +1,6 @@ + + @@ -10,4 +12,5 @@ fs-wg-container="Shipping_Contact_formD" fs-caption="Ship-To Contact" > + diff --git a/acumate-plugin/src/test/suite/htmlValidation.test.ts b/acumate-plugin/src/test/suite/htmlValidation.test.ts index 3b1f46a..4eb4be6 100644 --- a/acumate-plugin/src/test/suite/htmlValidation.test.ts +++ b/acumate-plugin/src/test/suite/htmlValidation.test.ts @@ -220,7 +220,7 @@ describe('HTML validation diagnostics', () => { ); }); - it('accepts qp-field, qp-label, and qp-include without id attributes', async () => { + it('accepts id-optional qp controls without id attributes', async () => { const document = await openFixtureDocument('TestControlIdOptional.html'); await validateHtmlFile(document); const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; diff --git a/acumate-plugin/src/validation/htmlValidation/html-validation.ts b/acumate-plugin/src/validation/htmlValidation/html-validation.ts index e11353d..6f0d4a1 100644 --- a/acumate-plugin/src/validation/htmlValidation/html-validation.ts +++ b/acumate-plugin/src/validation/htmlValidation/html-validation.ts @@ -33,7 +33,14 @@ import { createSuppressionEngine, SuppressionEngine } from "../../diagnostics/su // The validator turns the TypeScript model into CollectedClassInfo entries for every PXScreen/PXView // and then uses that metadata when validating the HTML DOM. const includeIntrinsicAttributes = new Set(["id", "class", "style", "slot"]); -const idOptionalTags = new Set(["qp-field", "qp-label", "qp-include"]); +const idOptionalTags = new Set([ + "qp-field", + "qp-label", + "qp-include", + "qp-informer-rack", + "qp-nested-screen", + "qp-wait-cursor", +]); interface IncludeTemplateFieldValidationContext { classInfoMap: Map; From 34f0fe3b6d15b2fdbbf8e5f432a039491dba6358 Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Tue, 12 May 2026 15:52:24 +0200 Subject: [PATCH 08/21] Allow id-less longrun indicator --- acumate-plugin/src/test/fixtures/html/TestControlIdOptional.html | 1 + acumate-plugin/src/validation/htmlValidation/html-validation.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/acumate-plugin/src/test/fixtures/html/TestControlIdOptional.html b/acumate-plugin/src/test/fixtures/html/TestControlIdOptional.html index cbf899c..0f452fb 100644 --- a/acumate-plugin/src/test/fixtures/html/TestControlIdOptional.html +++ b/acumate-plugin/src/test/fixtures/html/TestControlIdOptional.html @@ -1,6 +1,7 @@ + diff --git a/acumate-plugin/src/validation/htmlValidation/html-validation.ts b/acumate-plugin/src/validation/htmlValidation/html-validation.ts index 6f0d4a1..84de1a0 100644 --- a/acumate-plugin/src/validation/htmlValidation/html-validation.ts +++ b/acumate-plugin/src/validation/htmlValidation/html-validation.ts @@ -38,6 +38,7 @@ const idOptionalTags = new Set([ "qp-label", "qp-include", "qp-informer-rack", + "qp-longrun-indicator", "qp-nested-screen", "qp-wait-cursor", ]); From fa3d2728c1ffa3d374d44c65578222c787ec03ed Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Tue, 12 May 2026 16:04:37 +0200 Subject: [PATCH 09/21] Ignore mustache-templated HTML bindings --- .../fixtures/html/TestTemplateBindings.html | 17 ++++ .../fixtures/html/TestTemplateBindings.ts | 3 + .../src/test/suite/htmlValidation.test.ts | 7 ++ .../htmlValidation/html-validation.ts | 88 +++++++++++++++---- 4 files changed, 99 insertions(+), 16 deletions(-) create mode 100644 acumate-plugin/src/test/fixtures/html/TestTemplateBindings.html create mode 100644 acumate-plugin/src/test/fixtures/html/TestTemplateBindings.ts diff --git a/acumate-plugin/src/test/fixtures/html/TestTemplateBindings.html b/acumate-plugin/src/test/fixtures/html/TestTemplateBindings.html new file mode 100644 index 0000000..d6f463d --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestTemplateBindings.html @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/acumate-plugin/src/test/fixtures/html/TestTemplateBindings.ts b/acumate-plugin/src/test/fixtures/html/TestTemplateBindings.ts new file mode 100644 index 0000000..ee80349 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestTemplateBindings.ts @@ -0,0 +1,3 @@ +import { HtmlTestMaint } from "./TestScreen"; + +export class TestTemplateBindings extends HtmlTestMaint {} diff --git a/acumate-plugin/src/test/suite/htmlValidation.test.ts b/acumate-plugin/src/test/suite/htmlValidation.test.ts index 4eb4be6..da30b46 100644 --- a/acumate-plugin/src/test/suite/htmlValidation.test.ts +++ b/acumate-plugin/src/test/suite/htmlValidation.test.ts @@ -346,6 +346,13 @@ describe('HTML validation diagnostics', () => { ); }); + it('ignores validations for mustache-templated HTML bindings', async () => { + const document = await openFixtureDocument('TestTemplateBindings.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.strictEqual(diagnostics.length, 0, 'Expected no diagnostics for runtime-templated bindings'); + }); + it('accepts qp-button config.bind when config matches schema', async () => { const document = await openFixtureDocument('TestConfigBindingValid.html'); await validateHtmlFile(document); diff --git a/acumate-plugin/src/validation/htmlValidation/html-validation.ts b/acumate-plugin/src/validation/htmlValidation/html-validation.ts index 84de1a0..6cae519 100644 --- a/acumate-plugin/src/validation/htmlValidation/html-validation.ts +++ b/acumate-plugin/src/validation/htmlValidation/html-validation.ts @@ -191,7 +191,7 @@ function validateDom( function getIncludeFieldValidationContext(node: any): IncludeFieldValidationContext | undefined { const includeUrl = node.attribs?.url; - if (typeof includeUrl !== "string" || !includeUrl.length) { + if (typeof includeUrl !== "string" || !includeUrl.length || hasTemplateExpression(includeUrl)) { return undefined; } @@ -281,7 +281,8 @@ function validateDom( hasScreenMetadata && node.type === "tag" && node.name === "qp-fieldset" && - node.attribs[`view.bind`] + node.attribs[`view.bind`] && + !hasTemplateExpression(node.attribs[`view.bind`]) ) { const viewName = node.attribs[`view.bind`]; const viewResolution = resolveView(viewName); @@ -303,7 +304,7 @@ function validateDom( } if (hasScreenMetadata && node.type === "tag" && node.name === "qp-panel") { - if (elementId.length) { + if (elementId.length && !hasTemplateExpression(elementId)) { const viewResolution = resolveView(elementId); if (!viewResolution) { const range = getRange(content, node); @@ -319,7 +320,13 @@ function validateDom( } } - if (hasScreenMetadata && node.type === "tag" && node.name === "using" && node.attribs.view) { + if ( + hasScreenMetadata && + node.type === "tag" && + node.name === "using" && + node.attribs.view && + !hasTemplateExpression(node.attribs.view) + ) { const viewName = node.attribs.view; const viewResolution = resolveView(viewName); const hasValidView = @@ -340,7 +347,12 @@ function validateDom( } const actionBinding = node.attribs?.["state.bind"]; - if (canValidateActions && typeof actionBinding === "string" && actionBinding.length) { + if ( + canValidateActions && + typeof actionBinding === "string" && + actionBinding.length && + !hasTemplateExpression(actionBinding) + ) { const panelHasAction = panelViewContext?.properties.get(actionBinding)?.kind === "action"; if (!actionLookup.has(actionBinding) && !panelHasAction) { const range = getRange(content, node); @@ -412,14 +424,17 @@ function validateDom( if (!viewName) { viewName = getViewNameFromCustomizationSelectors(node); } + if (!viewName && hasTemplateCustomizationSelector(node)) { + return; + } if (!viewName && includeFieldContext) { const includeViewName = getViewNameFromIncludeCustomizationSelectors(node, includeFieldContext); - includeViewNameAllowsAnyViewFallback = hasIncludeTemplateExpression(includeViewName); + includeViewNameAllowsAnyViewFallback = hasTemplateExpression(includeViewName); viewName = includeViewName; } if (includeFieldContext) { - includeViewNameAllowsAnyViewFallback ||= hasIncludeTemplateExpression(viewName); + includeViewNameAllowsAnyViewFallback ||= hasTemplateExpression(viewName); viewName = resolveIncludeTemplateValue(viewName, includeFieldContext); } @@ -427,6 +442,9 @@ function validateDom( const fieldName = includeFieldContext ? resolveIncludeTemplateValue(rawFieldName, includeFieldContext) : rawFieldName; + if (hasTemplateExpression(viewName) || hasTemplateExpression(fieldName)) { + return; + } const viewResolution = resolveView(viewName); const viewClass = viewResolution?.viewClass; const fieldProperty = viewClass?.properties.get(fieldName); @@ -474,6 +492,10 @@ function validateDom( }); function validateTemplateName(templateName: string, node: any, insideDataFeed: boolean) { const normalizedTemplateName = templateName.trim(); + if (hasTemplateExpression(normalizedTemplateName)) { + return; + } + if (normalizedTemplateName.startsWith("record-") && !insideDataFeed) { const range = getRange(content, node); pushHtmlDiagnostic( @@ -506,6 +528,10 @@ function validateDom( } forEachCustomizationSelector(node, (attributeName, rawValue, normalizedValue) => { + if (hasTemplateExpression(normalizedValue)) { + return; + } + const range = getAttributeValueRange(content, node, attributeName, rawValue) ?? getRange(content, node); const { nodes, error } = queryBaseScreenElements(baseScreenDocument, normalizedValue); @@ -542,6 +568,10 @@ function validateDom( return; } + if (hasTemplateExpression(normalizedValue)) { + return; + } + const { nodes, error } = queryBaseScreenElements(baseScreenDocument, normalizedValue); if (error || !nodes.length) { return; @@ -574,6 +604,10 @@ function validateDom( return; } + if (hasTemplateExpression(normalizedValue)) { + return; + } + const { nodes, error } = queryBaseScreenElements(includeDocument, normalizedValue); if (error || !nodes.length) { return; @@ -597,12 +631,12 @@ function validateDom( context: IncludeFieldValidationContext | undefined, allowAnyViewFallback = false ): boolean { - if (!context || !fieldName || hasIncludeTemplateExpression(fieldName)) { + if (!context || !fieldName || hasTemplateExpression(fieldName)) { return false; } const normalizedViewName = viewName?.trim(); - if (normalizedViewName && !hasIncludeTemplateExpression(normalizedViewName)) { + if (normalizedViewName && !hasTemplateExpression(normalizedViewName)) { const viewResolution = resolveIncludeView(normalizedViewName, context); const fieldProperty = viewResolution?.viewClass?.properties.get(fieldName); if (fieldProperty?.kind === "field") { @@ -665,10 +699,6 @@ function validateDom( }).trim(); } - function hasIncludeTemplateExpression(value: string | undefined): boolean { - return typeof value === "string" && /{{\s*[^}]+\s*}}/.test(value); - } - function forEachCustomizationSelector( node: any, callback: (attributeName: string, rawValue: string, normalizedValue: string) => void @@ -691,6 +721,16 @@ function validateDom( } } + function hasTemplateCustomizationSelector(node: any): boolean { + let hasTemplateSelector = false; + forEachCustomizationSelector(node, (_attributeName, _rawValue, normalizedValue) => { + if (hasTemplateExpression(normalizedValue)) { + hasTemplateSelector = true; + } + }); + return hasTemplateSelector; + } + function validateConfigBinding(bindingValue: string, node: any) { const trimmed = bindingValue.trim(); if (!trimmed.startsWith("{")) { @@ -707,6 +747,10 @@ function validateDom( const configObject = parseConfigObject(bindingValue); const range = getRange(content, node); if (!configObject) { + if (hasTemplateExpression(bindingValue)) { + return; + } + pushHtmlDiagnostic( diagnostics, suppression, @@ -752,7 +796,7 @@ function validateDom( } const normalizedValue = rawValue.trim(); - if (!normalizedValue.length) { + if (!normalizedValue.length || hasTemplateExpression(normalizedValue)) { return; } @@ -773,6 +817,10 @@ function validateDom( function validateControlStateBinding(bindingValue: string, node: any) { + if (hasTemplateExpression(bindingValue)) { + return; + } + const parts = bindingValue.split("."); const range = getRange(content, node); if (parts.length !== 2) { @@ -827,7 +875,11 @@ function validateDom( } const configObject = parseConfigObject(rawConfig); - return Boolean(configObject && Object.prototype.hasOwnProperty.call(configObject, "id")); + if (configObject) { + return Object.prototype.hasOwnProperty.call(configObject, "id"); + } + + return hasTemplateExpression(rawConfig); } } @@ -840,7 +892,7 @@ function validateIncludeNode( suppression: SuppressionEngine ) { const includeUrl = node.attribs?.url; - if (typeof includeUrl !== "string" || !includeUrl.length) { + if (typeof includeUrl !== "string" || !includeUrl.length || hasTemplateExpression(includeUrl)) { return; } @@ -904,6 +956,10 @@ function shouldIgnoreIncludeAttribute(attributeName: string): boolean { return false; } +function hasTemplateExpression(value: string | undefined): boolean { + return typeof value === "string" && /{{\s*[^}]+\s*}}/.test(value); +} + function getAttributeValueRange( content: string, node: any, From 6e72a0ea62c2c41ff45ba9cf07cf680632c5d31c Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Tue, 12 May 2026 16:13:24 +0200 Subject: [PATCH 10/21] Ignore unbound field names --- .../src/providers/html-definition-provider.ts | 12 ++++++++++++ .../test/fixtures/html/TestScreenUnboundField.html | 4 ++-- acumate-plugin/src/test/suite/htmlProviders.test.ts | 10 ++++++++++ acumate-plugin/src/test/suite/htmlValidation.test.ts | 4 ++-- .../src/validation/htmlValidation/html-validation.ts | 6 ++---- 5 files changed, 28 insertions(+), 8 deletions(-) diff --git a/acumate-plugin/src/providers/html-definition-provider.ts b/acumate-plugin/src/providers/html-definition-provider.ts index 9b1f612..ccdb317 100644 --- a/acumate-plugin/src/providers/html-definition-provider.ts +++ b/acumate-plugin/src/providers/html-definition-provider.ts @@ -105,6 +105,14 @@ export class HtmlDefinitionProvider implements vscode.DefinitionProvider { } } + if ( + attributeContext.attributeName === 'name' && + attributeContext.tagName === 'field' && + hasUnboundAttribute(elementNode) + ) { + return; + } + if (attributeContext.attributeName === 'name' && attributeContext.tagName === 'field' && includeContext) { const includeLocations = getFieldDefinitionLocations( attributeContext.value, @@ -240,6 +248,10 @@ function getIncludeDefinitionContext( }; } +function hasUnboundAttribute(elementNode: any): boolean { + return Boolean(elementNode?.attribs && Object.prototype.hasOwnProperty.call(elementNode.attribs, 'unbound')); +} + function findNearestIncludeNode(node: any): any | undefined { let current = node; while (current) { diff --git a/acumate-plugin/src/test/fixtures/html/TestScreenUnboundField.html b/acumate-plugin/src/test/fixtures/html/TestScreenUnboundField.html index e6c5c6a..6102bed 100644 --- a/acumate-plugin/src/test/fixtures/html/TestScreenUnboundField.html +++ b/acumate-plugin/src/test/fixtures/html/TestScreenUnboundField.html @@ -1,7 +1,7 @@ - - + + diff --git a/acumate-plugin/src/test/suite/htmlProviders.test.ts b/acumate-plugin/src/test/suite/htmlProviders.test.ts index ad0acfa..70aed1a 100644 --- a/acumate-plugin/src/test/suite/htmlProviders.test.ts +++ b/acumate-plugin/src/test/suite/htmlProviders.test.ts @@ -413,6 +413,16 @@ describe('HTML definition provider integration', () => { ); }); + it('does not navigate from unbound field names', async () => { + const htmlPath = path.join(fixturesRoot, 'TestScreenUnboundField.html'); + const document = await vscode.workspace.openTextDocument(htmlPath); + const provider = new HtmlDefinitionProvider(); + const caret = positionAt(document, 'name="actions"', 'name="'.length + 1); + const definition = await provider.provideDefinition(document, caret); + const locations = Array.isArray(definition) ? definition : definition ? [definition] : []; + assert.strictEqual(locations.length, 0, 'Expected no definitions for unbound field name'); + }); + it('navigates from field inside using container with custom view', async () => { const document = await vscode.workspace.openTextDocument(usingFixturePath); const provider = new HtmlDefinitionProvider(); diff --git a/acumate-plugin/src/test/suite/htmlValidation.test.ts b/acumate-plugin/src/test/suite/htmlValidation.test.ts index da30b46..e88561c 100644 --- a/acumate-plugin/src/test/suite/htmlValidation.test.ts +++ b/acumate-plugin/src/test/suite/htmlValidation.test.ts @@ -127,11 +127,11 @@ describe('HTML validation diagnostics', () => { assert.strictEqual(diagnostics.length, 0, 'Expected no diagnostics for PXView mixin html'); }); - it('ignores fake fields marked unbound replace-content', async () => { + it('ignores unbound field names', async () => { const document = await openFixtureDocument('TestScreenUnboundField.html'); await validateHtmlFile(document); const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; - assert.strictEqual(diagnostics.length, 0, 'Expected no diagnostics for unbound replace-content fields'); + assert.strictEqual(diagnostics.length, 0, 'Expected no diagnostics for unbound fields'); }); it('reports invalid PXAction references in state.bind attributes', async () => { diff --git a/acumate-plugin/src/validation/htmlValidation/html-validation.ts b/acumate-plugin/src/validation/htmlValidation/html-validation.ts index 6cae519..1ea81c6 100644 --- a/acumate-plugin/src/validation/htmlValidation/html-validation.ts +++ b/acumate-plugin/src/validation/htmlValidation/html-validation.ts @@ -414,11 +414,9 @@ function validateDom( const viewSpecified = node.attribs.name.includes("."); const [viewFromNameAttribute, fieldFromNameAttribute] = viewSpecified ? node.attribs.name.split(".") : []; - const isUnboundReplacement = - Object.prototype.hasOwnProperty.call(node.attribs, "unbound") && - Object.prototype.hasOwnProperty.call(node.attribs, "replace-content"); + const isUnboundField = Object.prototype.hasOwnProperty.call(node.attribs, "unbound"); - if (!isUnboundReplacement) { + if (!isUnboundField) { let viewName = viewSpecified ? viewFromNameAttribute : findParentViewName(node); let includeViewNameAllowsAnyViewFallback = false; if (!viewName) { From c7e4d21302853c346e57c4c62ae84edcc718f46b Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Tue, 12 May 2026 16:20:35 +0200 Subject: [PATCH 11/21] Allow id-less qp shell components --- .../src/test/fixtures/html/TestControlIdOptional.html | 5 +++++ .../src/validation/htmlValidation/html-validation.ts | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/acumate-plugin/src/test/fixtures/html/TestControlIdOptional.html b/acumate-plugin/src/test/fixtures/html/TestControlIdOptional.html index 0f452fb..713b5d9 100644 --- a/acumate-plugin/src/test/fixtures/html/TestControlIdOptional.html +++ b/acumate-plugin/src/test/fixtures/html/TestControlIdOptional.html @@ -2,6 +2,11 @@ + + + + + diff --git a/acumate-plugin/src/validation/htmlValidation/html-validation.ts b/acumate-plugin/src/validation/htmlValidation/html-validation.ts index 1ea81c6..7849ab3 100644 --- a/acumate-plugin/src/validation/htmlValidation/html-validation.ts +++ b/acumate-plugin/src/validation/htmlValidation/html-validation.ts @@ -35,12 +35,17 @@ import { createSuppressionEngine, SuppressionEngine } from "../../diagnostics/su const includeIntrinsicAttributes = new Set(["id", "class", "style", "slot"]); const idOptionalTags = new Set([ "qp-field", + "qp-data-component", + "qp-data-components", "qp-label", "qp-include", "qp-informer-rack", "qp-longrun-indicator", "qp-nested-screen", + "qp-screen-configuration-menu", + "qp-translation-validation", "qp-wait-cursor", + "qp-wiki-tooltip", ]); interface IncludeTemplateFieldValidationContext { From dccfe6bff3aa9723d1843d2fdab4a2c343f7d73e Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Wed, 13 May 2026 09:43:56 +0200 Subject: [PATCH 12/21] Resolve state actions from view scope --- .../src/providers/html-completion-provider.ts | 30 +++++++++++++++++-- .../src/providers/html-definition-provider.ts | 21 ++++++++++++- .../test/fixtures/html/TestScreenUsing.html | 2 ++ .../src/test/fixtures/html/TestScreenUsing.ts | 2 ++ .../src/test/suite/htmlProviders.test.ts | 23 ++++++++++++++ .../src/test/suite/htmlValidation.test.ts | 11 +++++++ .../htmlValidation/html-validation.ts | 4 ++- 7 files changed, 88 insertions(+), 5 deletions(-) diff --git a/acumate-plugin/src/providers/html-completion-provider.ts b/acumate-plugin/src/providers/html-completion-provider.ts index 8d72c6e..3468b67 100644 --- a/acumate-plugin/src/providers/html-completion-provider.ts +++ b/acumate-plugin/src/providers/html-completion-provider.ts @@ -121,7 +121,7 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider { } if (attributeContext.attributeName === 'state.bind') { - return this.createActionCompletions(screenClasses); + return this.createActionCompletions(screenClasses, classInfoLookup, elementNode); } if (attributeContext.attributeName === 'control-state.bind' && attributeContext.tagName === 'qp-field') { @@ -590,14 +590,38 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider { return items; } - private createActionCompletions(screenClasses: CollectedClassInfo[]): vscode.CompletionItem[] { + private createActionCompletions( + screenClasses: CollectedClassInfo[], + classInfoLookup: Map, + elementNode: any + ): vscode.CompletionItem[] { 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; + } + 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); + } }); + return items; } diff --git a/acumate-plugin/src/providers/html-definition-provider.ts b/acumate-plugin/src/providers/html-definition-provider.ts index ccdb317..33caee9 100644 --- a/acumate-plugin/src/providers/html-definition-provider.ts +++ b/acumate-plugin/src/providers/html-definition-provider.ts @@ -182,7 +182,9 @@ export class HtmlDefinitionProvider implements vscode.DefinitionProvider { } if (attributeContext.attributeName === 'state.bind') { - const actionProperty = findActionProperty(attributeContext.value, screenClasses); + const actionProperty = + findActionProperty(attributeContext.value, screenClasses) ?? + findViewActionProperty(attributeContext.value, elementNode, documentMetadataContext); if (!actionProperty) { return; } @@ -435,6 +437,23 @@ 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 parseControlStateBinding(value: string | undefined): { viewName: string; fieldName: string } | undefined { if (!value) { return undefined; diff --git a/acumate-plugin/src/test/fixtures/html/TestScreenUsing.html b/acumate-plugin/src/test/fixtures/html/TestScreenUsing.html index 46d322f..9093f19 100644 --- a/acumate-plugin/src/test/fixtures/html/TestScreenUsing.html +++ b/acumate-plugin/src/test/fixtures/html/TestScreenUsing.html @@ -15,6 +15,8 @@ + + diff --git a/acumate-plugin/src/test/fixtures/html/TestScreenUsing.ts b/acumate-plugin/src/test/fixtures/html/TestScreenUsing.ts index ba3343f..8041b4e 100644 --- a/acumate-plugin/src/test/fixtures/html/TestScreenUsing.ts +++ b/acumate-plugin/src/test/fixtures/html/TestScreenUsing.ts @@ -11,6 +11,8 @@ export class ProdItemSelectedView extends PXView { export class ItemConfigurationView extends PXView { ConfigurationID!: PXFieldState; Revision!: PXFieldState; + ConfigureEntry!: PXActionState; + Reconfigure!: PXActionState; } export class TestScreenUsingMaint extends PXScreen { diff --git a/acumate-plugin/src/test/suite/htmlProviders.test.ts b/acumate-plugin/src/test/suite/htmlProviders.test.ts index 70aed1a..024a552 100644 --- a/acumate-plugin/src/test/suite/htmlProviders.test.ts +++ b/acumate-plugin/src/test/suite/htmlProviders.test.ts @@ -289,6 +289,16 @@ describe('HTML completion provider integration', () => { assert.ok(labels.includes('AddBlanketLineOK'), 'AddBlanketLineOK action not suggested'); }); + it('suggests PXAction names from using view context', async () => { + const document = await vscode.workspace.openTextDocument(usingFixturePath); + const provider = new HtmlCompletionProvider(); + const caret = positionAt(document, 'state.bind="ConfigureEntry"', 'state.bind="'.length + 1); + const completions = await provider.provideCompletionItems(document, caret); + assert.ok(completions && completions.length > 0, 'No completions returned for using view state.bind'); + const labels = completions.map(item => item.label); + assert.ok(labels.includes('ConfigureEntry'), 'ConfigureEntry not suggested from using view'); + }); + it('suggests field names for using containers inheriting parent views', async () => { const document = await vscode.workspace.openTextDocument(usingFixturePath); const provider = new HtmlCompletionProvider(); @@ -553,6 +563,19 @@ describe('HTML definition provider integration', () => { ); }); + it('navigates from using view state.bind attribute to PXAction definition', async () => { + const document = await vscode.workspace.openTextDocument(usingFixturePath); + const provider = new HtmlDefinitionProvider(); + const caret = positionAt(document, 'state.bind="ConfigureEntry"', 'state.bind="'.length + 1); + const definition = await provider.provideDefinition(document, caret); + const locations = Array.isArray(definition) ? definition : definition ? [definition] : []; + assert.ok(locations.length >= 1, 'No definitions returned'); + assert.ok( + locations.some(loc => loc.uri.fsPath.endsWith('TestScreenUsing.ts')), + 'Expected using view action definition inside TestScreenUsing.ts' + ); + }); + it('navigates from qp-include url to referenced file', async () => { const document = await vscode.workspace.openTextDocument(includeHostPath); const provider = new HtmlDefinitionProvider(); diff --git a/acumate-plugin/src/test/suite/htmlValidation.test.ts b/acumate-plugin/src/test/suite/htmlValidation.test.ts index e88561c..836de5f 100644 --- a/acumate-plugin/src/test/suite/htmlValidation.test.ts +++ b/acumate-plugin/src/test/suite/htmlValidation.test.ts @@ -90,6 +90,17 @@ describe('HTML validation diagnostics', () => { assert.ok(diagnostics.some(d => d.message.includes('')), 'Expected invalid using view diagnostic'); }); + it('accepts actions declared on the current using view', async () => { + const document = await openFixtureDocument('TestScreenUsing.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.strictEqual( + diagnostics.filter(d => d.message.includes('state.bind attribute must reference a valid PXAction')).length, + 0, + 'Expected no invalid PXAction diagnostics when binding to using view actions' + ); + }); + it('accepts valid screen extension html by combining screen metadata', async () => { const document = await vscode.workspace.openTextDocument(screenExtensionFixture); await validateHtmlFile(document); diff --git a/acumate-plugin/src/validation/htmlValidation/html-validation.ts b/acumate-plugin/src/validation/htmlValidation/html-validation.ts index 7849ab3..8d1ac30 100644 --- a/acumate-plugin/src/validation/htmlValidation/html-validation.ts +++ b/acumate-plugin/src/validation/htmlValidation/html-validation.ts @@ -359,7 +359,9 @@ function validateDom( !hasTemplateExpression(actionBinding) ) { const panelHasAction = panelViewContext?.properties.get(actionBinding)?.kind === "action"; - if (!actionLookup.has(actionBinding) && !panelHasAction) { + const scopedViewName = findParentViewName(node); + const viewHasAction = resolveView(scopedViewName)?.viewClass?.properties.get(actionBinding)?.kind === "action"; + if (!actionLookup.has(actionBinding) && !panelHasAction && !viewHasAction) { const range = getRange(content, node); pushHtmlDiagnostic( diagnostics, From f1cb9dfdce093f62b2d621655e64d7b10202798d Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Wed, 13 May 2026 10:12:48 +0200 Subject: [PATCH 13/21] Resolve duplicate view classes by import source --- .../src/providers/html-definition-provider.ts | 5 +-- .../fixtures/html/TestDuplicateViewNames.html | 5 +++ .../html/TestDuplicateViewNames.models.ts | 3 ++ .../html/TestDuplicateViewNames.other.ts | 3 ++ .../fixtures/html/TestDuplicateViewNames.ts | 6 +++ .../src/test/suite/htmlProviders.test.ts | 14 ++++++ .../src/test/suite/htmlValidation.test.ts | 11 +++++ acumate-plugin/src/utils.ts | 44 ++++++++++++++++++- .../tsValidation/graph-info-validation.ts | 3 +- 9 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.html create mode 100644 acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.models.ts create mode 100644 acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.other.ts create mode 100644 acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.ts diff --git a/acumate-plugin/src/providers/html-definition-provider.ts b/acumate-plugin/src/providers/html-definition-provider.ts index 33caee9..44952ad 100644 --- a/acumate-plugin/src/providers/html-definition-provider.ts +++ b/acumate-plugin/src/providers/html-definition-provider.ts @@ -12,6 +12,7 @@ import { collectActionProperties, filterClassesBySource, getLineAndColumnFromIndex, + resolveClassInfoForProperty, } from '../utils'; import { parseDocumentDom, @@ -391,9 +392,7 @@ function getFieldDefinitionsFromAnyView( continue; } - const viewClass = property.viewClassName - ? metadataContext.classInfoLookup.get(property.viewClassName) - : undefined; + const viewClass = resolveClassInfoForProperty(property, metadataContext.classInfoLookup); const fieldProperty = viewClass?.properties.get(fieldName); if (fieldProperty?.kind !== 'field') { continue; diff --git a/acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.html b/acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.html new file mode 100644 index 0000000..834cc78 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.html @@ -0,0 +1,5 @@ + + + + + diff --git a/acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.models.ts b/acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.models.ts new file mode 100644 index 0000000..2a26652 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.models.ts @@ -0,0 +1,3 @@ +export class SelectionFilter extends PXView { + WcID!: PXFieldState; +} diff --git a/acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.other.ts b/acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.other.ts new file mode 100644 index 0000000..817d66f --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.other.ts @@ -0,0 +1,3 @@ +export class SelectionFilter extends PXView { + OtherField!: PXFieldState; +} diff --git a/acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.ts b/acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.ts new file mode 100644 index 0000000..c4d2778 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.ts @@ -0,0 +1,6 @@ +import { SelectionFilter } from "./TestDuplicateViewNames.models"; +import "./TestDuplicateViewNames.other"; + +export class TestDuplicateViewNames extends PXScreen { + SelectionFilter = createSingle(SelectionFilter); +} diff --git a/acumate-plugin/src/test/suite/htmlProviders.test.ts b/acumate-plugin/src/test/suite/htmlProviders.test.ts index 024a552..55daff9 100644 --- a/acumate-plugin/src/test/suite/htmlProviders.test.ts +++ b/acumate-plugin/src/test/suite/htmlProviders.test.ts @@ -23,6 +23,7 @@ const includeFieldModificationHostPath = path.join(fixturesRoot, 'TestIncludeFie const importedFixturePath = path.join(fixturesRoot, 'TestScreenImported.html'); const configCompletionPath = path.join(fixturesRoot, 'TestConfigBindingCompletion.html'); const controlTypeCompletionPath = path.join(fixturesRoot, 'TestControlTypeCompletion.html'); +const duplicateViewNamesFixturePath = path.join(fixturesRoot, 'TestDuplicateViewNames.html'); const screenFixturesRoot = path.resolve(__dirname, '../../../src/test/fixtures/screens'); const screenExtensionHtmlPath = path.join( screenFixturesRoot, @@ -423,6 +424,19 @@ describe('HTML definition provider integration', () => { ); }); + it('navigates to the imported view class when class names collide', async () => { + const document = await vscode.workspace.openTextDocument(duplicateViewNamesFixturePath); + const provider = new HtmlDefinitionProvider(); + const caret = positionAt(document, 'name="WcID"', 'name="'.length + 1); + const definition = await provider.provideDefinition(document, caret); + const locations = Array.isArray(definition) ? definition : definition ? [definition] : []; + assert.ok(locations.length >= 1, 'No definitions returned'); + assert.ok( + locations.some(loc => loc.uri.fsPath.endsWith('TestDuplicateViewNames.models.ts')), + 'Expected definition inside the directly imported SelectionFilter model' + ); + }); + it('does not navigate from unbound field names', async () => { const htmlPath = path.join(fixturesRoot, 'TestScreenUnboundField.html'); const document = await vscode.workspace.openTextDocument(htmlPath); diff --git a/acumate-plugin/src/test/suite/htmlValidation.test.ts b/acumate-plugin/src/test/suite/htmlValidation.test.ts index 836de5f..958217c 100644 --- a/acumate-plugin/src/test/suite/htmlValidation.test.ts +++ b/acumate-plugin/src/test/suite/htmlValidation.test.ts @@ -101,6 +101,17 @@ describe('HTML validation diagnostics', () => { ); }); + it('resolves view classes by import source when class names collide', async () => { + const document = await openFixtureDocument('TestDuplicateViewNames.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.strictEqual( + diagnostics.filter(d => d.message.includes('WcID')).length, + 0, + 'Expected WcID to resolve against the imported SelectionFilter view class' + ); + }); + it('accepts valid screen extension html by combining screen metadata', async () => { const document = await vscode.workspace.openTextDocument(screenExtensionFixture); await validateHtmlFile(document); diff --git a/acumate-plugin/src/utils.ts b/acumate-plugin/src/utils.ts index e9f535e..468feff 100644 --- a/acumate-plugin/src/utils.ts +++ b/acumate-plugin/src/utils.ts @@ -550,7 +550,47 @@ export function getClassPropertiesFromTs( } export function createClassInfoLookup(classInfos: CollectedClassInfo[]): Map { - return new Map(classInfos.map(info => [info.className, info])); + const lookup = new Map(); + for (const info of classInfos) { + if (!lookup.has(info.className)) { + lookup.set(info.className, info); + } + lookup.set(getClassInfoLookupKey(info.sourceFile.fileName, info.className), info); + } + return lookup; +} + +function getClassInfoLookupKey(fileName: string | undefined, className: string): string { + return `${path.normalize(fileName ?? '')}::${className}`; +} + +export function resolveClassInfoForProperty( + property: ClassPropertyInfo, + classInfoLookup: Map +): CollectedClassInfo | undefined { + const className = property.viewClassName; + if (!className) { + return undefined; + } + + const sourceFileName = property.sourceFile.fileName; + const localClass = classInfoLookup.get(getClassInfoLookupKey(sourceFileName, className)); + if (localClass) { + return localClass; + } + + const moduleSpecifier = getImportModuleSpecifier(property.sourceFile, className); + if (moduleSpecifier) { + const resolvedPath = resolveModulePath(sourceFileName, moduleSpecifier); + const importedClass = resolvedPath + ? classInfoLookup.get(getClassInfoLookupKey(resolvedPath, className)) + : undefined; + if (importedClass) { + return importedClass; + } + } + + return classInfoLookup.get(className); } export interface ViewResolution { @@ -578,7 +618,7 @@ export function resolveViewBinding( continue; } - const viewClass = property.viewClassName ? classInfoLookup.get(property.viewClassName) : undefined; + const viewClass = resolveClassInfoForProperty(property, classInfoLookup); return { screenClass, property, viewClass }; } diff --git a/acumate-plugin/src/validation/tsValidation/graph-info-validation.ts b/acumate-plugin/src/validation/tsValidation/graph-info-validation.ts index 0f50d86..1821cb8 100644 --- a/acumate-plugin/src/validation/tsValidation/graph-info-validation.ts +++ b/acumate-plugin/src/validation/tsValidation/graph-info-validation.ts @@ -15,6 +15,7 @@ import { ClassPropertyInfo, createClassInfoLookup, isScreenLikeClass, + resolveClassInfoForProperty, tryGetGraphTypeFromExtension } from '../../utils'; import { buildBackendActionSet, buildBackendViewMap, normalizeMetaName } from '../../backend-metadata-utils'; @@ -269,7 +270,7 @@ function compareViewClassesWithGraph( } const backendView = backendViewMap.get(normalizedViewName); - const viewClassInfo = classInfoLookup.get(property.viewClassName); + const viewClassInfo = resolveClassInfoForProperty(property, classInfoLookup); if (!viewClassInfo) { continue; } From c84db42c2bba08092f449c47592a6c9b2814e9bc Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Wed, 13 May 2026 11:42:48 +0200 Subject: [PATCH 14/21] Resolve include child selectors with host mixins --- .../src/providers/html-definition-provider.ts | 19 +++- .../include-extension-mixin-template.html | 5 ++ .../include-extension-mixin-template.ts | 7 ++ .../SO301000_IncludeExtensionMixin.html | 6 ++ .../SO301000_IncludeExtensionMixin.ts | 13 +++ .../src/test/suite/htmlProviders.test.ts | 20 +++++ .../src/test/suite/htmlValidation.test.ts | 21 +++++ .../htmlValidation/html-validation.ts | 88 ++++++++++++++----- 8 files changed, 155 insertions(+), 24 deletions(-) create mode 100644 acumate-plugin/src/test/fixtures/includes/include-extension-mixin-template.html create mode 100644 acumate-plugin/src/test/fixtures/includes/include-extension-mixin-template.ts create mode 100644 acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_IncludeExtensionMixin.html create mode 100644 acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_IncludeExtensionMixin.ts diff --git a/acumate-plugin/src/providers/html-definition-provider.ts b/acumate-plugin/src/providers/html-definition-provider.ts index 44952ad..65bffa4 100644 --- a/acumate-plugin/src/providers/html-definition-provider.ts +++ b/acumate-plugin/src/providers/html-definition-provider.ts @@ -1,5 +1,6 @@ import vscode from 'vscode'; import ts from 'typescript'; +import path from 'path'; import { getRelatedTsFiles, @@ -240,7 +241,9 @@ function getIncludeDefinitionContext( } const includeTsFilePaths = getRelatedTsFiles(includePath); - const classInfos = includeTsFilePaths.length ? loadClassInfosFromFiles(includeTsFilePaths) : []; + const hostTsFilePaths = getRelatedTsFiles(documentPath); + const combinedTsFilePaths = dedupeFilePaths([...hostTsFilePaths, ...includeTsFilePaths]); + const classInfos = combinedTsFilePaths.length ? loadClassInfosFromFiles(combinedTsFilePaths) : []; const relevantClassInfos = filterClassesBySource(classInfos, includeTsFilePaths); return { @@ -428,6 +431,20 @@ function hasTemplateExpression(value: string | undefined): boolean { return typeof value === 'string' && /{{\s*[^}]+\s*}}/.test(value); } +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; +} + function findActionProperty(actionName: string | undefined, screenClasses: CollectedClassInfo[]): ClassPropertyInfo | undefined { if (!actionName) { return undefined; diff --git a/acumate-plugin/src/test/fixtures/includes/include-extension-mixin-template.html b/acumate-plugin/src/test/fixtures/includes/include-extension-mixin-template.html new file mode 100644 index 0000000..1705a59 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/includes/include-extension-mixin-template.html @@ -0,0 +1,5 @@ + + + + + diff --git a/acumate-plugin/src/test/fixtures/includes/include-extension-mixin-template.ts b/acumate-plugin/src/test/fixtures/includes/include-extension-mixin-template.ts new file mode 100644 index 0000000..bc68ca9 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/includes/include-extension-mixin-template.ts @@ -0,0 +1,7 @@ +export abstract class IncludeExtensionMixinBase { + addItemParameters = createSingle(IncludeExtensionMixinParameters); +} + +export class IncludeExtensionMixinParameters extends PXView { + BaseParam!: PXFieldState; +} diff --git a/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_IncludeExtensionMixin.html b/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_IncludeExtensionMixin.html new file mode 100644 index 0000000..4ddad52 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_IncludeExtensionMixin.html @@ -0,0 +1,6 @@ + diff --git a/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_IncludeExtensionMixin.ts b/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_IncludeExtensionMixin.ts new file mode 100644 index 0000000..05cfe0b --- /dev/null +++ b/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_IncludeExtensionMixin.ts @@ -0,0 +1,13 @@ +import { SO301000 } from "../SO301000"; +import { + IncludeExtensionMixinBase, + IncludeExtensionMixinParameters, +} from "../../../../includes/include-extension-mixin-template"; + +export interface SO301000_IncludeExtensionMixin extends SO301000, IncludeExtensionMixinBase { } +export class SO301000_IncludeExtensionMixin extends IncludeExtensionMixinBase { } + +export interface SO301000_IncludeExtensionMixinParameters extends IncludeExtensionMixinParameters { } +export class SO301000_IncludeExtensionMixinParameters { + VendorID!: PXFieldState; +} diff --git a/acumate-plugin/src/test/suite/htmlProviders.test.ts b/acumate-plugin/src/test/suite/htmlProviders.test.ts index 55daff9..e26246b 100644 --- a/acumate-plugin/src/test/suite/htmlProviders.test.ts +++ b/acumate-plugin/src/test/suite/htmlProviders.test.ts @@ -60,6 +60,13 @@ const screenDependentExtensionHtmlPath = path.join( 'extensions', 'SO301000_Discounts.html' ); +const screenIncludeExtensionMixinHtmlPath = path.join( + screenFixturesRoot, + 'SO', + 'SO301000', + 'extensions', + 'SO301000_IncludeExtensionMixin.html' +); const fieldControlHtmlPath = path.join(fixturesRoot, 'FieldControlInfo.html'); const backendGraphName = 'PX.SM.ProjectNewUiFrontendFileMaintenance'; @@ -701,6 +708,19 @@ describe('HTML definition provider integration', () => { ); }); + it('navigates from qp-include child field name to host extension mixin definition', async () => { + const document = await vscode.workspace.openTextDocument(screenIncludeExtensionMixinHtmlPath); + const provider = new HtmlDefinitionProvider(); + const caret = positionAt(document, 'name="VendorID"', 'name="'.length + 1); + const definition = await provider.provideDefinition(document, caret); + const locations = Array.isArray(definition) ? definition : definition ? [definition] : []; + assert.ok(locations.length >= 1, 'No definitions returned for include extension mixin field'); + assert.ok( + locations.some(loc => loc.uri.fsPath.endsWith('SO301000_IncludeExtensionMixin.ts')), + 'Expected navigation to host extension mixin TS file' + ); + }); + it('navigates from selector-injected field name to TS definition', async () => { const document = await vscode.workspace.openTextDocument(screenSelectorHtmlPath); const provider = new HtmlDefinitionProvider(); diff --git a/acumate-plugin/src/test/suite/htmlValidation.test.ts b/acumate-plugin/src/test/suite/htmlValidation.test.ts index 958217c..a3917e2 100644 --- a/acumate-plugin/src/test/suite/htmlValidation.test.ts +++ b/acumate-plugin/src/test/suite/htmlValidation.test.ts @@ -29,6 +29,13 @@ const screenDependentExtensionFixture = path.join( 'extensions', 'SO301000_Discounts.html' ); +const screenIncludeExtensionMixinFixture = path.join( + screenFixturesRoot, + 'SO', + 'SO301000', + 'extensions', + 'SO301000_IncludeExtensionMixin.html' +); async function openFixtureDocument(fileName: string) { const fullPath = path.join(fixturesRoot, fileName); @@ -447,6 +454,20 @@ describe('HTML validation diagnostics', () => { ); }); + it('resolves qp-include child selectors and fields against include template metadata with host mixins', async () => { + const document = await vscode.workspace.openTextDocument(screenIncludeExtensionMixinFixture); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.strictEqual( + diagnostics.filter(d => + d.message.includes('fsColumnB-IncludeMixin') || + d.message.includes('"VendorID"') + ).length, + 0, + 'Expected include child selector and extension-mixin field to validate' + ); + }); + it('derives view metadata from selector targets when lacks a parent view', async () => { const document = await vscode.workspace.openTextDocument(screenSelectorExtensionFixture); await validateHtmlFile(document); diff --git a/acumate-plugin/src/validation/htmlValidation/html-validation.ts b/acumate-plugin/src/validation/htmlValidation/html-validation.ts index 8d1ac30..8f39006 100644 --- a/acumate-plugin/src/validation/htmlValidation/html-validation.ts +++ b/acumate-plugin/src/validation/htmlValidation/html-validation.ts @@ -13,6 +13,7 @@ import { collectActionProperties, parseConfigObject, filterClassesBySource, + resolveClassInfoForProperty, } from "../../utils"; import { findParentViewName, findViewNameAtOrAbove } from "../../providers/html-shared"; import { getIncludeMetadata, resolveIncludeFilePath } from "../../services/include-service"; @@ -206,10 +207,15 @@ function validateDom( } const normalizedIncludePath = path.normalize(includeHtmlPath); - let templateContext = includeFieldContextCache.get(normalizedIncludePath); - if (!includeFieldContextCache.has(normalizedIncludePath)) { - templateContext = loadIncludeTemplateFieldValidationContext(normalizedIncludePath); - includeFieldContextCache.set(normalizedIncludePath, templateContext); + const hostTsFilePaths = getRelatedTsFiles(htmlFilePath); + const cacheKey = [ + normalizedIncludePath, + ...hostTsFilePaths.map((filePath) => path.normalize(filePath)), + ].join("|"); + let templateContext = includeFieldContextCache.get(cacheKey); + if (!includeFieldContextCache.has(cacheKey)) { + templateContext = loadIncludeTemplateFieldValidationContext(normalizedIncludePath, hostTsFilePaths); + includeFieldContextCache.set(cacheKey, templateContext); } if (!templateContext) { @@ -223,11 +229,13 @@ function validateDom( } function loadIncludeTemplateFieldValidationContext( - includeHtmlPath: string + includeHtmlPath: string, + hostTsFilePaths: string[] ): IncludeTemplateFieldValidationContext | undefined { const includeTsFilePaths = getRelatedTsFiles(includeHtmlPath); - const includeClassProperties = includeTsFilePaths.length - ? loadClassInfosFromFiles(includeTsFilePaths) + const combinedTsFilePaths = dedupeFilePaths([...hostTsFilePaths, ...includeTsFilePaths]); + const includeClassProperties = combinedTsFilePaths.length + ? loadClassInfosFromFiles(combinedTsFilePaths) : []; const includeRelevantClassInfos = filterClassesBySource(includeClassProperties, includeTsFilePaths); const screenClasses = filterScreenLikeClasses(includeRelevantClassInfos); @@ -528,7 +536,8 @@ function validateDom( } function validateCustomizationSelectors(node: any) { - if (!baseScreenDocument) { + const selectorDocuments = getSelectorValidationDocuments(); + if (!selectorDocuments.length) { return; } @@ -539,29 +548,50 @@ function validateDom( const range = getAttributeValueRange(content, node, attributeName, rawValue) ?? getRange(content, node); - const { nodes, error } = queryBaseScreenElements(baseScreenDocument, normalizedValue); - if (error) { - pushHtmlDiagnostic( - diagnostics, - suppression, - range, - `The ${attributeName} selector "${rawValue}" is not a valid CSS selector (${error}).` - ); - return; + + let selectorMatched = false; + for (const document of selectorDocuments) { + const { nodes, error } = queryBaseScreenElements(document, normalizedValue); + if (error) { + pushHtmlDiagnostic( + diagnostics, + suppression, + range, + `The ${attributeName} selector "${rawValue}" is not a valid CSS selector (${error}).` + ); + return; + } + + if (nodes.length) { + selectorMatched = true; + break; + } } - if (!nodes.length) { - const baseName = getScreenDocumentDisplayName(baseScreenDocument); + if (!selectorMatched) { + const documentNames = selectorDocuments.map(getScreenDocumentDisplayName).join(" or "); pushHtmlDiagnostic( diagnostics, suppression, range, - `The ${attributeName} selector "${rawValue}" does not match any elements in ${baseName}.` + `The ${attributeName} selector "${rawValue}" does not match any elements in ${documentNames}.` ); } }); } + function getSelectorValidationDocuments(): BaseScreenDocument[] { + const documents: BaseScreenDocument[] = []; + const includeDocument = includeFieldContext?.templateDocument; + if (includeDocument) { + documents.push(includeDocument); + } + if (baseScreenDocument) { + documents.push(baseScreenDocument); + } + return documents; + } + function getViewNameFromCustomizationSelectors(node: any): string | undefined { if (!baseScreenDocument) { return undefined; @@ -677,9 +707,7 @@ function validateDom( continue; } - const viewClass = property.viewClassName - ? context.classInfoMap.get(property.viewClassName) - : undefined; + const viewClass = resolveClassInfoForProperty(property, context.classInfoMap); const fieldProperty = viewClass?.properties.get(fieldName); if (fieldProperty?.kind === "field") { return true; @@ -965,6 +993,20 @@ function hasTemplateExpression(value: string | undefined): boolean { return typeof value === "string" && /{{\s*[^}]+\s*}}/.test(value); } +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; +} + function getAttributeValueRange( content: string, node: any, From 018f91b6b3aa89f830e69696286cb35572ccab98 Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Wed, 13 May 2026 11:52:44 +0200 Subject: [PATCH 15/21] Complete include child fields from host mixins --- .../src/providers/html-completion-provider.ts | 199 +++++++++++++++++- .../src/test/suite/htmlProviders.test.ts | 20 ++ 2 files changed, 217 insertions(+), 2 deletions(-) diff --git a/acumate-plugin/src/providers/html-completion-provider.ts b/acumate-plugin/src/providers/html-completion-provider.ts index 3468b67..762dc73 100644 --- a/acumate-plugin/src/providers/html-completion-provider.ts +++ b/acumate-plugin/src/providers/html-completion-provider.ts @@ -1,4 +1,5 @@ import vscode from 'vscode'; +import path from 'path'; import { getRelatedTsFiles, @@ -18,15 +19,30 @@ import { elevateToElementNode, getAttributeContext, findParentViewName, + findViewNameAtOrAbove, } from './html-shared'; import { ClientControlMetadata, getClientControlsMetadata, } from '../services/client-controls-service'; import { getScreenTemplates } from '../services/screen-template-service'; -import { getIncludeMetadata, IncludeMetadata } from '../services/include-service'; +import { getIncludeMetadata, IncludeMetadata, resolveIncludeFilePath } from '../services/include-service'; import { BackendFieldMetadata, normalizeMetaName } from '../backend-metadata-utils'; import { loadBackendFieldsForView } from './html-backend-utils'; +import { + BaseScreenDocument, + getBaseScreenDocument, + getCustomizationSelectorAttributes, + loadHtmlDocument, + queryBaseScreenElements, +} from '../services/screen-html-service'; + +interface IncludeFieldCompletionContext { + classInfoLookup: Map; + screenClasses: CollectedClassInfo[]; + templateDocument?: BaseScreenDocument; + parameterValues: Map; +} // Registers completions so HTML view bindings stay in sync with PX metadata. export function registerHtmlCompletionProvider(context: vscode.ExtensionContext) { @@ -130,7 +146,21 @@ 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 = this.getIncludeFieldCompletionContext( + document.uri.fsPath, + elementNode, + tsFilePaths + ); + if (includeContext) { + const items = this.createIncludeFieldNameCompletions(elementNode, includeContext); + if (items?.length) { + return items; + } + } + + const viewName = + findParentViewName(elementNode) ?? + this.getViewNameFromCustomizationSelectors(elementNode, getBaseScreenDocument(document.uri.fsPath)); if (!viewName) { return undefined; } @@ -366,6 +396,171 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider { return items.length ? items : undefined; } + private getIncludeFieldCompletionContext( + documentPath: string, + elementNode: any, + hostTsFilePaths: string[] + ): IncludeFieldCompletionContext | undefined { + const includeNode = this.findNearestIncludeNode(elementNode); + if (!includeNode) { + return undefined; + } + + const includeUrl = includeNode.attribs?.url; + const workspaceRoots = vscode.workspace.workspaceFolders?.map(folder => folder.uri.fsPath); + const includePath = resolveIncludeFilePath(includeUrl, documentPath, workspaceRoots); + if (!includePath) { + return undefined; + } + + const includeTsFilePaths = getRelatedTsFiles(includePath); + const combinedTsFilePaths = this.dedupeFilePaths([...hostTsFilePaths, ...includeTsFilePaths]); + const classInfos = combinedTsFilePaths.length ? loadClassInfosFromFiles(combinedTsFilePaths) : []; + const relevantClassInfos = filterClassesBySource(classInfos, includeTsFilePaths); + const screenClasses = filterScreenLikeClasses(relevantClassInfos); + if (!screenClasses.length) { + return undefined; + } + + return { + classInfoLookup: createClassInfoLookup(classInfos), + screenClasses, + templateDocument: loadHtmlDocument(includePath), + parameterValues: this.getIncludeParameterValues(includeNode), + }; + } + + private createIncludeFieldNameCompletions( + elementNode: any, + context: IncludeFieldCompletionContext + ): vscode.CompletionItem[] | undefined { + const selectorViewName = this.getViewNameFromCustomizationSelectors(elementNode, context.templateDocument); + const viewName = this.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; + } + + return this.createFieldCompletionsFromViews(context.screenClasses, context.classInfoLookup); + } + + private createFieldCompletionsFromViews( + screenClasses: CollectedClassInfo[], + classInfoLookup: Map + ): vscode.CompletionItem[] | undefined { + const fields = new Map(); + for (const screenClass of screenClasses) { + for (const propertyName of screenClass.properties.keys()) { + const resolution = resolveViewBinding(propertyName, [screenClass], classInfoLookup); + const viewClass = resolution?.viewClass; + if (!viewClass) { + continue; + } + + for (const [fieldName, fieldProperty] of viewClass.properties) { + if (fieldProperty.kind === 'field' && !fields.has(fieldName)) { + fields.set(fieldName, fieldProperty); + } + } + } + } + + return fields.size ? this.createFieldCompletions(fields) : undefined; + } + + private 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 || this.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; + } + + private 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; + } + + private 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; + } + + private 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(); + } + + private hasTemplateExpression(value: string | undefined): boolean { + return typeof value === 'string' && /{{\s*[^}]+\s*}}/.test(value); + } + + private 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; + } + private isInsideDataFeed(node: any): boolean { let current = node?.parent ?? node?.parentNode; while (current) { diff --git a/acumate-plugin/src/test/suite/htmlProviders.test.ts b/acumate-plugin/src/test/suite/htmlProviders.test.ts index e26246b..a0c6ec1 100644 --- a/acumate-plugin/src/test/suite/htmlProviders.test.ts +++ b/acumate-plugin/src/test/suite/htmlProviders.test.ts @@ -307,6 +307,26 @@ describe('HTML completion provider integration', () => { assert.ok(labels.includes('ConfigureEntry'), 'ConfigureEntry not suggested from using view'); }); + it('suggests field names from selector target view context', async () => { + const document = await vscode.workspace.openTextDocument(screenSelectorHtmlPath); + const provider = new HtmlCompletionProvider(); + const caret = positionAt(document, 'name="AMCuryEstimateTotal"', 'name="'.length + 1); + const completions = await provider.provideCompletionItems(document, caret); + assert.ok(completions && completions.length > 0, 'No field completions returned for selector target view'); + const labels = completions.map(item => item.label); + assert.ok(labels.includes('AMCuryEstimateTotal'), 'AMCuryEstimateTotal not suggested from selector target view'); + }); + + it('suggests qp-include child field names from host extension mixins', async () => { + const document = await vscode.workspace.openTextDocument(screenIncludeExtensionMixinHtmlPath); + const provider = new HtmlCompletionProvider(); + const caret = positionAt(document, 'name="VendorID"', 'name="'.length + 1); + const completions = await provider.provideCompletionItems(document, caret); + assert.ok(completions && completions.length > 0, 'No field completions returned for include child field'); + const labels = completions.map(item => item.label); + assert.ok(labels.includes('VendorID'), 'VendorID not suggested from include host extension mixin'); + }); + it('suggests field names for using containers inheriting parent views', async () => { const document = await vscode.workspace.openTextDocument(usingFixturePath); const provider = new HtmlCompletionProvider(); From d0599560f896382ae1d14be87f7171719beb1f51 Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Wed, 13 May 2026 12:06:09 +0200 Subject: [PATCH 16/21] Resolve HTML field hover contexts --- .../src/providers/html-hover-provider.ts | 277 +++++++++++++++++- .../src/test/suite/htmlProviders.test.ts | 58 ++++ 2 files changed, 324 insertions(+), 11 deletions(-) diff --git a/acumate-plugin/src/providers/html-hover-provider.ts b/acumate-plugin/src/providers/html-hover-provider.ts index a5293ef..114819a 100644 --- a/acumate-plugin/src/providers/html-hover-provider.ts +++ b/acumate-plugin/src/providers/html-hover-provider.ts @@ -1,10 +1,12 @@ import vscode from 'vscode'; +import path from 'path'; import { parseDocumentDom, findNodeAtOffset, elevateToElementNode, getAttributeContext, findParentViewName, + findViewNameAtOrAbove, HtmlAttributeContext, } from './html-shared'; import { @@ -14,9 +16,32 @@ import { createClassInfoLookup, filterScreenLikeClasses, resolveViewBinding, + CollectedClassInfo, } from '../utils'; import { loadBackendFieldsForView } from './html-backend-utils'; import { BackendFieldMetadata, normalizeMetaName } from '../backend-metadata-utils'; +import { resolveIncludeFilePath } from '../services/include-service'; +import { + BaseScreenDocument, + getBaseScreenDocument, + getCustomizationSelectorAttributes, + loadHtmlDocument, + queryBaseScreenElements, +} from '../services/screen-html-service'; + +interface FieldHoverResolution { + fieldName: string; + viewName: string; + backendScreenClasses: CollectedClassInfo[]; +} + +interface IncludeFieldHoverContext { + classInfoLookup: Map; + screenClasses: CollectedClassInfo[]; + backendScreenClasses: CollectedClassInfo[]; + templateDocument?: BaseScreenDocument; + parameterValues: Map; +} export function registerHtmlHoverProvider(context: vscode.ExtensionContext) { const provider = vscode.languages.registerHoverProvider( @@ -99,23 +124,24 @@ async function buildFieldHover( return undefined; } - const viewName = findParentViewName(elementNode); - if (!viewName) { - return undefined; - } - const classInfoLookup = createClassInfoLookup(classInfos); - const resolution = resolveViewBinding(viewName, screenClasses, classInfoLookup); - if (!resolution) { + const fieldReference = parseFieldName(fieldName); + const hoverResolution = + resolveIncludeFieldHover(htmlFilePath, elementNode, tsFilePaths, screenClasses, fieldReference) ?? + resolveHostFieldHover(htmlFilePath, elementNode, screenClasses, classInfoLookup, fieldReference); + if (!hoverResolution) { return undefined; } - const backendFields = await loadBackendFieldsForView(viewName, screenClasses); + const backendFields = await loadBackendFieldsForView( + hoverResolution.viewName, + hoverResolution.backendScreenClasses + ); if (!backendFields?.size) { return undefined; } - const normalizedFieldName = normalizeMetaName(fieldName); + const normalizedFieldName = normalizeMetaName(hoverResolution.fieldName); if (!normalizedFieldName) { return undefined; } @@ -125,7 +151,7 @@ async function buildFieldHover( return undefined; } - const markdown = buildFieldMarkdown(backendField, fieldName, viewName); + const markdown = buildFieldMarkdown(backendField, hoverResolution.fieldName, hoverResolution.viewName); if (!markdown) { return undefined; } @@ -133,6 +159,235 @@ async function buildFieldHover( return new vscode.Hover(markdown, attributeContext.valueRange); } +function resolveHostFieldHover( + htmlFilePath: string, + elementNode: any, + screenClasses: CollectedClassInfo[], + classInfoLookup: Map, + fieldReference: FieldReference +): FieldHoverResolution | undefined { + const viewName = + fieldReference.viewName ?? + findParentViewName(elementNode) ?? + getViewNameFromCustomizationSelectors(elementNode, getBaseScreenDocument(htmlFilePath)); + if (!viewName || hasTemplateExpression(viewName) || hasTemplateExpression(fieldReference.fieldName)) { + return undefined; + } + + const resolution = resolveViewBinding(viewName, screenClasses, classInfoLookup); + if (!resolution) { + return undefined; + } + + return { + fieldName: fieldReference.fieldName, + viewName, + backendScreenClasses: screenClasses, + }; +} + +function resolveIncludeFieldHover( + htmlFilePath: string, + elementNode: any, + hostTsFilePaths: string[], + hostScreenClasses: CollectedClassInfo[], + fieldReference: FieldReference +): FieldHoverResolution | undefined { + const context = getIncludeFieldHoverContext( + htmlFilePath, + elementNode, + hostTsFilePaths, + hostScreenClasses + ); + if (!context) { + return undefined; + } + + const viewName = + fieldReference.viewName ?? + resolveTemplateValue( + getViewNameFromCustomizationSelectors(elementNode, context.templateDocument), + context.parameterValues + ) ?? + findViewNameContainingField(fieldReference.fieldName, context); + if (!viewName || hasTemplateExpression(viewName) || hasTemplateExpression(fieldReference.fieldName)) { + return undefined; + } + + const resolution = resolveViewBinding(viewName, context.screenClasses, context.classInfoLookup); + if (!resolution) { + return undefined; + } + + return { + fieldName: fieldReference.fieldName, + viewName, + backendScreenClasses: context.backendScreenClasses, + }; +} + +function getIncludeFieldHoverContext( + htmlFilePath: string, + elementNode: any, + hostTsFilePaths: string[], + hostScreenClasses: CollectedClassInfo[] +): IncludeFieldHoverContext | undefined { + const includeNode = findNearestIncludeNode(elementNode); + const includeUrl = includeNode?.attribs?.url; + if (typeof includeUrl !== 'string' || !includeUrl.length) { + return undefined; + } + + const workspaceRoots = vscode.workspace.workspaceFolders?.map(folder => folder.uri.fsPath); + const includePath = resolveIncludeFilePath(includeUrl, htmlFilePath, workspaceRoots); + if (!includePath) { + return undefined; + } + + const includeTsFilePaths = getRelatedTsFiles(includePath); + const combinedTsFilePaths = dedupeFilePaths([...hostTsFilePaths, ...includeTsFilePaths]); + const classInfos = combinedTsFilePaths.length ? loadClassInfosFromFiles(combinedTsFilePaths) : []; + const relevantClassInfos = filterClassesBySource(classInfos, includeTsFilePaths); + const screenClasses = filterScreenLikeClasses(relevantClassInfos); + if (!screenClasses.length) { + return undefined; + } + + return { + classInfoLookup: createClassInfoLookup(classInfos), + screenClasses, + backendScreenClasses: hostScreenClasses, + templateDocument: loadHtmlDocument(includePath), + parameterValues: getIncludeParameterValues(includeNode), + }; +} + +interface FieldReference { + viewName?: string; + fieldName: string; +} + +function parseFieldName(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 }; +} + +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; +} + +function findViewNameContainingField( + fieldName: string, + context: IncludeFieldHoverContext +): string | undefined { + for (const screenClass of context.screenClasses) { + for (const propertyName of screenClass.properties.keys()) { + const resolution = resolveViewBinding(propertyName, [screenClass], context.classInfoLookup); + const fieldProperty = resolution?.viewClass?.properties.get(fieldName); + if (fieldProperty?.kind === 'field') { + return propertyName; + } + } + } + + return undefined; +} + +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; +} + +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; +} + +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(); +} + +function hasTemplateExpression(value: string | undefined): boolean { + return typeof value === 'string' && /{{\s*[^}]+\s*}}/.test(value); +} + +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; +} + function buildFieldMarkdown( metadata: BackendFieldMetadata, fieldName: string, @@ -156,4 +411,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/test/suite/htmlProviders.test.ts b/acumate-plugin/src/test/suite/htmlProviders.test.ts index a0c6ec1..577cc84 100644 --- a/acumate-plugin/src/test/suite/htmlProviders.test.ts +++ b/acumate-plugin/src/test/suite/htmlProviders.test.ts @@ -214,6 +214,64 @@ describe('HTML completion provider integration', () => { assert.ok(/qp-drop-down/.test(value), 'Hover should show default control type'); }); + it('shows backend metadata for fields scoped by selector target view', async () => { + const graphStructure: GraphStructure = { + name: backendGraphName, + views: { + CurrentDocument: { + name: 'CurrentDocument', + fields: { + AMCuryEstimateTotal: { + name: 'AMCuryEstimateTotal', + displayName: 'Manufacturing Estimate Total', + typeName: 'PX.Objects.CS.PXDecimal' + } + } + } + } + }; + AcuMateContext.ApiService = new HtmlMockApiClient({ [backendGraphName]: graphStructure }); + + const document = await vscode.workspace.openTextDocument(screenSelectorHtmlPath); + const caret = positionAt(document, 'name="AMCuryEstimateTotal"', 'name="'.length + 1); + const hover = await provideHtmlFieldHover(document, caret); + assert.ok(hover, 'Expected hover result for selector-scoped HTML field'); + const contents = Array.isArray(hover!.contents) ? hover!.contents : [hover!.contents]; + const first = contents[0]; + const value = first instanceof vscode.MarkdownString ? first.value : `${first}`; + assert.ok(/Manufacturing Estimate Total/.test(value), 'Hover should show selector-scoped backend display name'); + assert.ok(/CurrentDocument/.test(value), 'Hover should show selector target view name'); + }); + + it('shows backend metadata for qp-include child fields from host extension mixins', async () => { + const graphStructure: GraphStructure = { + name: backendGraphName, + views: { + addItemParameters: { + name: 'addItemParameters', + fields: { + VendorID: { + name: 'VendorID', + displayName: 'Vendor', + typeName: 'PX.Objects.AP.Vendor' + } + } + } + } + }; + AcuMateContext.ApiService = new HtmlMockApiClient({ [backendGraphName]: graphStructure }); + + const document = await vscode.workspace.openTextDocument(screenIncludeExtensionMixinHtmlPath); + const caret = positionAt(document, 'name="VendorID"', 'name="'.length + 1); + const hover = await provideHtmlFieldHover(document, caret); + assert.ok(hover, 'Expected hover result for include child extension-mixin field'); + const contents = Array.isArray(hover!.contents) ? hover!.contents : [hover!.contents]; + const first = contents[0]; + const value = first instanceof vscode.MarkdownString ? first.value : `${first}`; + assert.ok(/Vendor/.test(value), 'Hover should show include child backend display name'); + assert.ok(/addItemParameters/.test(value), 'Hover should show include template view name'); + }); + it('suggests view names for using view attribute', async () => { const document = await vscode.workspace.openTextDocument(usingFixturePath); const provider = new HtmlCompletionProvider(); From 358ef75a83d01a2014eb1b2c1f569ebfe6df2685 Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Wed, 13 May 2026 12:24:17 +0200 Subject: [PATCH 17/21] Share HTML field context resolution --- .../src/providers/html-completion-provider.ts | 223 ++------ .../src/providers/html-definition-provider.ts | 265 ++------- .../src/providers/html-hover-provider.ts | 337 ++---------- .../services/html-field-context-service.ts | 509 ++++++++++++++++++ .../htmlValidation/html-validation.ts | 369 +++---------- 5 files changed, 696 insertions(+), 1007 deletions(-) create mode 100644 acumate-plugin/src/services/html-field-context-service.ts diff --git a/acumate-plugin/src/providers/html-completion-provider.ts b/acumate-plugin/src/providers/html-completion-provider.ts index 762dc73..4e05e51 100644 --- a/acumate-plugin/src/providers/html-completion-provider.ts +++ b/acumate-plugin/src/providers/html-completion-provider.ts @@ -1,17 +1,13 @@ import vscode from 'vscode'; -import path from 'path'; import { getRelatedTsFiles, loadClassInfosFromFiles, CollectedClassInfo, - createClassInfoLookup, resolveViewBinding, ClassPropertyInfo, - filterScreenLikeClasses, collectActionProperties, extractConfigPropertyNames, - filterClassesBySource, } from '../utils'; import { parseDocumentDom, @@ -19,30 +15,25 @@ import { elevateToElementNode, getAttributeContext, findParentViewName, - findViewNameAtOrAbove, } from './html-shared'; import { ClientControlMetadata, getClientControlsMetadata, } from '../services/client-controls-service'; import { getScreenTemplates } from '../services/screen-template-service'; -import { getIncludeMetadata, IncludeMetadata, resolveIncludeFilePath } from '../services/include-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 { - BaseScreenDocument, - getBaseScreenDocument, - getCustomizationSelectorAttributes, - loadHtmlDocument, - queryBaseScreenElements, -} from '../services/screen-html-service'; - -interface IncludeFieldCompletionContext { - classInfoLookup: Map; - screenClasses: CollectedClassInfo[]; - templateDocument?: BaseScreenDocument; - parameterValues: Map; -} + 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) { @@ -119,13 +110,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') { @@ -146,21 +132,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 includeContext = this.getIncludeFieldCompletionContext( - document.uri.fsPath, + const includeContext = getIncludeFieldContext({ + documentPath: document.uri.fsPath, elementNode, - tsFilePaths - ); + 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 = - findParentViewName(elementNode) ?? - this.getViewNameFromCustomizationSelectors(elementNode, getBaseScreenDocument(document.uri.fsPath)); + const viewName = getParentOrSelectorViewName( + elementNode, + getBaseScreenDocument(document.uri.fsPath) + ); if (!viewName) { return undefined; } @@ -396,171 +396,22 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider { return items.length ? items : undefined; } - private getIncludeFieldCompletionContext( - documentPath: string, - elementNode: any, - hostTsFilePaths: string[] - ): IncludeFieldCompletionContext | undefined { - const includeNode = this.findNearestIncludeNode(elementNode); - if (!includeNode) { - return undefined; - } - - const includeUrl = includeNode.attribs?.url; - const workspaceRoots = vscode.workspace.workspaceFolders?.map(folder => folder.uri.fsPath); - const includePath = resolveIncludeFilePath(includeUrl, documentPath, workspaceRoots); - if (!includePath) { - return undefined; - } - - const includeTsFilePaths = getRelatedTsFiles(includePath); - const combinedTsFilePaths = this.dedupeFilePaths([...hostTsFilePaths, ...includeTsFilePaths]); - const classInfos = combinedTsFilePaths.length ? loadClassInfosFromFiles(combinedTsFilePaths) : []; - const relevantClassInfos = filterClassesBySource(classInfos, includeTsFilePaths); - const screenClasses = filterScreenLikeClasses(relevantClassInfos); - if (!screenClasses.length) { - return undefined; - } - - return { - classInfoLookup: createClassInfoLookup(classInfos), - screenClasses, - templateDocument: loadHtmlDocument(includePath), - parameterValues: this.getIncludeParameterValues(includeNode), - }; - } - private createIncludeFieldNameCompletions( elementNode: any, - context: IncludeFieldCompletionContext + context: HtmlIncludeFieldContext ): vscode.CompletionItem[] | undefined { - const selectorViewName = this.getViewNameFromCustomizationSelectors(elementNode, context.templateDocument); - const viewName = this.resolveTemplateValue(selectorViewName, context.parameterValues); + 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; } - return this.createFieldCompletionsFromViews(context.screenClasses, context.classInfoLookup); - } - - private createFieldCompletionsFromViews( - screenClasses: CollectedClassInfo[], - classInfoLookup: Map - ): vscode.CompletionItem[] | undefined { - const fields = new Map(); - for (const screenClass of screenClasses) { - for (const propertyName of screenClass.properties.keys()) { - const resolution = resolveViewBinding(propertyName, [screenClass], classInfoLookup); - const viewClass = resolution?.viewClass; - if (!viewClass) { - continue; - } - - for (const [fieldName, fieldProperty] of viewClass.properties) { - if (fieldProperty.kind === 'field' && !fields.has(fieldName)) { - fields.set(fieldName, fieldProperty); - } - } - } - } - + const fields = getFieldPropertiesFromViews(context); return fields.size ? this.createFieldCompletions(fields) : undefined; } - private 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 || this.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; - } - - private 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; - } - - private 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; - } - - private 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(); - } - - private hasTemplateExpression(value: string | undefined): boolean { - return typeof value === 'string' && /{{\s*[^}]+\s*}}/.test(value); - } - - private 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; - } - private isInsideDataFeed(node: any): boolean { let current = node?.parent ?? node?.parentNode; while (current) { diff --git a/acumate-plugin/src/providers/html-definition-provider.ts b/acumate-plugin/src/providers/html-definition-provider.ts index 65bffa4..6df806b 100644 --- a/acumate-plugin/src/providers/html-definition-provider.ts +++ b/acumate-plugin/src/providers/html-definition-provider.ts @@ -1,19 +1,14 @@ import vscode from 'vscode'; import ts from 'typescript'; -import path from 'path'; import { getRelatedTsFiles, loadClassInfosFromFiles, CollectedClassInfo, ClassPropertyInfo, - createClassInfoLookup, resolveViewBinding, - filterScreenLikeClasses, collectActionProperties, - filterClassesBySource, getLineAndColumnFromIndex, - resolveClassInfoForProperty, } from '../utils'; import { parseDocumentDom, @@ -21,7 +16,6 @@ import { elevateToElementNode, getAttributeContext, findParentViewName, - findViewNameAtOrAbove, } from './html-shared'; import { resolveIncludeFilePath } from '../services/include-service'; import { @@ -29,20 +23,20 @@ import { isCustomizationSelectorAttribute, queryBaseScreenElements, BaseScreenDocument, - getCustomizationSelectorAttributes, - loadHtmlDocument, getDocumentForNode, } from '../services/screen-html-service'; - -interface DefinitionMetadataContext { - classInfoLookup: Map; - screenClasses: CollectedClassInfo[]; -} - -interface IncludeDefinitionContext extends DefinitionMetadataContext { - templateDocument?: BaseScreenDocument; - parameterValues: Map; -} +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) { @@ -80,7 +74,13 @@ export class HtmlDefinitionProvider implements vscode.DefinitionProvider { const baseScreenDocument = getBaseScreenDocument(document.uri.fsPath); const workspaceRoots = vscode.workspace.workspaceFolders?.map(folder => folder.uri.fsPath); - const includeContext = getIncludeDefinitionContext(elementNode, document.uri.fsPath, workspaceRoots); + 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 includePath = resolveIncludeFilePath(attributeContext.value, document.uri.fsPath, workspaceRoots); @@ -129,7 +129,7 @@ export class HtmlDefinitionProvider implements vscode.DefinitionProvider { } } - const tsFilePaths = getRelatedTsFiles(document.uri.fsPath); + const tsFilePaths = hostTsFilePaths; if (!tsFilePaths.length) { return; } @@ -139,14 +139,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: DefinitionMetadataContext = { classInfoLookup, screenClasses }; + 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')) { @@ -224,63 +218,10 @@ export class HtmlDefinitionProvider implements vscode.DefinitionProvider { } } -function getIncludeDefinitionContext( - elementNode: any, - documentPath: string, - workspaceRoots: string[] | undefined -): IncludeDefinitionContext | undefined { - const includeNode = findNearestIncludeNode(elementNode); - const includeUrl = includeNode?.attribs?.url; - if (typeof includeUrl !== 'string' || !includeUrl.length) { - return undefined; - } - - const includePath = resolveIncludeFilePath(includeUrl, documentPath, workspaceRoots); - if (!includePath) { - return undefined; - } - - const includeTsFilePaths = getRelatedTsFiles(includePath); - const hostTsFilePaths = getRelatedTsFiles(documentPath); - const combinedTsFilePaths = dedupeFilePaths([...hostTsFilePaths, ...includeTsFilePaths]); - const classInfos = combinedTsFilePaths.length ? loadClassInfosFromFiles(combinedTsFilePaths) : []; - const relevantClassInfos = filterClassesBySource(classInfos, includeTsFilePaths); - - return { - classInfoLookup: createClassInfoLookup(classInfos), - screenClasses: filterScreenLikeClasses(relevantClassInfos), - templateDocument: loadHtmlDocument(includePath), - parameterValues: getIncludeParameterValues(includeNode), - }; -} - function hasUnboundAttribute(elementNode: any): boolean { return Boolean(elementNode?.attribs && Object.prototype.hasOwnProperty.call(elementNode.attribs, 'unbound')); } -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; -} - -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; -} - function getSelectorLocations( document: BaseScreenDocument | undefined, selector: string @@ -327,122 +268,29 @@ function getFieldDefinitionLocations( return []; } - const parsed = parseFieldName(rawFieldName); - let viewName = parsed.viewName; - let fieldName = parsed.fieldName; - let allowFallback = allowAnyViewFallback; - - if (!parsed.viewName) { - viewName = findParentViewName(elementNode); - } - if (!viewName) { - const selectorViewName = getViewNameFromCustomizationSelectors(elementNode, selectorDocument); - allowFallback ||= hasTemplateExpression(selectorViewName); - viewName = selectorViewName; - } - - if (parameterValues) { - allowFallback ||= hasTemplateExpression(viewName); - viewName = resolveTemplateValue(viewName, parameterValues); - fieldName = resolveTemplateValue(fieldName, parameterValues) ?? fieldName; - } - - if (!fieldName) { + const parsed = parseFieldReference(rawFieldName); + const resolution = resolveHtmlField({ + fieldReference: parsed, + elementNode, + metadataContext, + selectorDocument, + parameterValues, + allowAnyViewFallback, + }); + if (!resolution || resolution.hasTemplatedBinding) { return []; } - if (viewName && !hasTemplateExpression(viewName)) { - const resolution = resolveViewBinding(viewName, metadataContext.screenClasses, metadataContext.classInfoLookup); - const fieldProperty = resolution?.viewClass?.properties.get(fieldName); - if (fieldProperty?.kind === 'field') { - return [createLocationFromProperty(fieldProperty)]; - } - - if (!allowFallback) { - return []; - } + if (resolution.fieldProperty?.kind === 'field' && !resolution.usedAnyViewFallback) { + return [createLocationFromProperty(resolution.fieldProperty)]; } - if (!allowFallback) { + if (!allowAnyViewFallback) { return []; } - return getFieldDefinitionsFromAnyView(fieldName, metadataContext); -} - -function parseFieldName(rawFieldName: string): { viewName?: string; fieldName: string } { - 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 }; -} - -function getFieldDefinitionsFromAnyView( - fieldName: string, - metadataContext: DefinitionMetadataContext -): vscode.Location[] { - const locations: vscode.Location[] = []; - const seen = new Set(); - - for (const screenClass of metadataContext.screenClasses) { - for (const property of screenClass.properties.values()) { - if (property.kind !== 'view' && property.kind !== 'viewCollection') { - continue; - } - - const viewClass = resolveClassInfoForProperty(property, metadataContext.classInfoLookup); - const fieldProperty = viewClass?.properties.get(fieldName); - if (fieldProperty?.kind !== 'field') { - continue; - } - - const key = `${fieldProperty.sourceFile.fileName}:${fieldProperty.node.getStart()}`; - if (seen.has(key)) { - continue; - } - seen.add(key); - locations.push(createLocationFromProperty(fieldProperty)); - } - } - - return locations; -} - -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(); -} - -function hasTemplateExpression(value: string | undefined): boolean { - return typeof value === 'string' && /{{\s*[^}]+\s*}}/.test(value); -} - -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; + return findFieldsInAnyView(resolution.fieldName, metadataContext) + .map(match => createLocationFromProperty(match.fieldProperty)); } function findActionProperty(actionName: string | undefined, screenClasses: CollectedClassInfo[]): ClassPropertyInfo | undefined { @@ -502,45 +350,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 = findViewNameAtOrAbove(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 114819a..5b0cac6 100644 --- a/acumate-plugin/src/providers/html-hover-provider.ts +++ b/acumate-plugin/src/providers/html-hover-provider.ts @@ -1,47 +1,21 @@ import vscode from 'vscode'; -import path from 'path'; import { parseDocumentDom, findNodeAtOffset, elevateToElementNode, getAttributeContext, - findParentViewName, - findViewNameAtOrAbove, HtmlAttributeContext, } from './html-shared'; -import { - getRelatedTsFiles, - loadClassInfosFromFiles, - filterClassesBySource, - createClassInfoLookup, - filterScreenLikeClasses, - resolveViewBinding, - CollectedClassInfo, -} from '../utils'; +import { getRelatedTsFiles } from '../utils'; import { loadBackendFieldsForView } from './html-backend-utils'; import { BackendFieldMetadata, normalizeMetaName } from '../backend-metadata-utils'; -import { resolveIncludeFilePath } from '../services/include-service'; +import { getBaseScreenDocument } from '../services/screen-html-service'; import { - BaseScreenDocument, - getBaseScreenDocument, - getCustomizationSelectorAttributes, - loadHtmlDocument, - queryBaseScreenElements, -} from '../services/screen-html-service'; - -interface FieldHoverResolution { - fieldName: string; - viewName: string; - backendScreenClasses: CollectedClassInfo[]; -} - -interface IncludeFieldHoverContext { - classInfoLookup: Map; - screenClasses: CollectedClassInfo[]; - backendScreenClasses: CollectedClassInfo[]; - templateDocument?: BaseScreenDocument; - parameterValues: Map; -} + getIncludeFieldContext, + loadHtmlFieldMetadataContext, + parseFieldReference, + resolveHtmlField, +} from '../services/html-field-context-service'; export function registerHtmlHoverProvider(context: vscode.ExtensionContext) { const provider = vscode.languages.registerHoverProvider( @@ -105,37 +79,61 @@ 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) { + const hostContext = loadHtmlFieldMetadataContext(tsFilePaths); + if (!hostContext) { return undefined; } - const classInfoLookup = createClassInfoLookup(classInfos); - const fieldReference = parseFieldName(fieldName); + 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, + }) + : undefined; + const hostBaseResolution = resolveHtmlField({ + fieldReference, + elementNode, + metadataContext: hostContext, + selectorDocument: getBaseScreenDocument(htmlFilePath), + }); + const hostResolution = hostIncludeSelectorResolution?.viewResolution + ? hostIncludeSelectorResolution + : hostBaseResolution; const hoverResolution = - resolveIncludeFieldHover(htmlFilePath, elementNode, tsFilePaths, screenClasses, fieldReference) ?? - resolveHostFieldHover(htmlFilePath, elementNode, screenClasses, classInfoLookup, fieldReference); - if (!hoverResolution) { + includeResolution?.viewName && includeResolution.viewResolution && !includeResolution.hasTemplatedBinding + ? includeResolution + : hostResolution; + if (!hoverResolution?.viewName || hoverResolution.hasTemplatedBinding) { return undefined; } const backendFields = await loadBackendFieldsForView( hoverResolution.viewName, - hoverResolution.backendScreenClasses + hoverResolution === includeResolution + ? includeContext?.hostScreenClasses ?? hostContext.screenClasses + : hostContext.screenClasses ); if (!backendFields?.size) { return undefined; @@ -159,235 +157,6 @@ async function buildFieldHover( return new vscode.Hover(markdown, attributeContext.valueRange); } -function resolveHostFieldHover( - htmlFilePath: string, - elementNode: any, - screenClasses: CollectedClassInfo[], - classInfoLookup: Map, - fieldReference: FieldReference -): FieldHoverResolution | undefined { - const viewName = - fieldReference.viewName ?? - findParentViewName(elementNode) ?? - getViewNameFromCustomizationSelectors(elementNode, getBaseScreenDocument(htmlFilePath)); - if (!viewName || hasTemplateExpression(viewName) || hasTemplateExpression(fieldReference.fieldName)) { - return undefined; - } - - const resolution = resolveViewBinding(viewName, screenClasses, classInfoLookup); - if (!resolution) { - return undefined; - } - - return { - fieldName: fieldReference.fieldName, - viewName, - backendScreenClasses: screenClasses, - }; -} - -function resolveIncludeFieldHover( - htmlFilePath: string, - elementNode: any, - hostTsFilePaths: string[], - hostScreenClasses: CollectedClassInfo[], - fieldReference: FieldReference -): FieldHoverResolution | undefined { - const context = getIncludeFieldHoverContext( - htmlFilePath, - elementNode, - hostTsFilePaths, - hostScreenClasses - ); - if (!context) { - return undefined; - } - - const viewName = - fieldReference.viewName ?? - resolveTemplateValue( - getViewNameFromCustomizationSelectors(elementNode, context.templateDocument), - context.parameterValues - ) ?? - findViewNameContainingField(fieldReference.fieldName, context); - if (!viewName || hasTemplateExpression(viewName) || hasTemplateExpression(fieldReference.fieldName)) { - return undefined; - } - - const resolution = resolveViewBinding(viewName, context.screenClasses, context.classInfoLookup); - if (!resolution) { - return undefined; - } - - return { - fieldName: fieldReference.fieldName, - viewName, - backendScreenClasses: context.backendScreenClasses, - }; -} - -function getIncludeFieldHoverContext( - htmlFilePath: string, - elementNode: any, - hostTsFilePaths: string[], - hostScreenClasses: CollectedClassInfo[] -): IncludeFieldHoverContext | undefined { - const includeNode = findNearestIncludeNode(elementNode); - const includeUrl = includeNode?.attribs?.url; - if (typeof includeUrl !== 'string' || !includeUrl.length) { - return undefined; - } - - const workspaceRoots = vscode.workspace.workspaceFolders?.map(folder => folder.uri.fsPath); - const includePath = resolveIncludeFilePath(includeUrl, htmlFilePath, workspaceRoots); - if (!includePath) { - return undefined; - } - - const includeTsFilePaths = getRelatedTsFiles(includePath); - const combinedTsFilePaths = dedupeFilePaths([...hostTsFilePaths, ...includeTsFilePaths]); - const classInfos = combinedTsFilePaths.length ? loadClassInfosFromFiles(combinedTsFilePaths) : []; - const relevantClassInfos = filterClassesBySource(classInfos, includeTsFilePaths); - const screenClasses = filterScreenLikeClasses(relevantClassInfos); - if (!screenClasses.length) { - return undefined; - } - - return { - classInfoLookup: createClassInfoLookup(classInfos), - screenClasses, - backendScreenClasses: hostScreenClasses, - templateDocument: loadHtmlDocument(includePath), - parameterValues: getIncludeParameterValues(includeNode), - }; -} - -interface FieldReference { - viewName?: string; - fieldName: string; -} - -function parseFieldName(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 }; -} - -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; -} - -function findViewNameContainingField( - fieldName: string, - context: IncludeFieldHoverContext -): string | undefined { - for (const screenClass of context.screenClasses) { - for (const propertyName of screenClass.properties.keys()) { - const resolution = resolveViewBinding(propertyName, [screenClass], context.classInfoLookup); - const fieldProperty = resolution?.viewClass?.properties.get(fieldName); - if (fieldProperty?.kind === 'field') { - return propertyName; - } - } - } - - return undefined; -} - -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; -} - -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; -} - -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(); -} - -function hasTemplateExpression(value: string | undefined): boolean { - return typeof value === 'string' && /{{\s*[^}]+\s*}}/.test(value); -} - -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; -} - function buildFieldMarkdown( metadata: BackendFieldMetadata, fieldName: 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..d533b16 --- /dev/null +++ b/acumate-plugin/src/services/html-field-context-service.ts @@ -0,0 +1,509 @@ +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, + 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; + } + + return { + ...templateContext, + includeNode, + includePath: normalizedIncludePath, + parameterValues: getIncludeParameterValues(includeNode), + 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/validation/htmlValidation/html-validation.ts b/acumate-plugin/src/validation/htmlValidation/html-validation.ts index 8f39006..06819b6 100644 --- a/acumate-plugin/src/validation/htmlValidation/html-validation.ts +++ b/acumate-plugin/src/validation/htmlValidation/html-validation.ts @@ -1,4 +1,3 @@ -import path from "path"; import vscode from "vscode"; import { Parser, DomHandler } from "htmlparser2"; import { @@ -13,23 +12,28 @@ import { collectActionProperties, parseConfigObject, filterClassesBySource, - resolveClassInfoForProperty, } from "../../utils"; -import { findParentViewName, findViewNameAtOrAbove } from "../../providers/html-shared"; -import { getIncludeMetadata, resolveIncludeFilePath } from "../../services/include-service"; +import { findParentViewName } from "../../providers/html-shared"; +import { getIncludeMetadata } from "../../services/include-service"; import { getScreenTemplates } from "../../services/screen-template-service"; import { getClientControlsMetadata, ClientControlMetadata } from "../../services/client-controls-service"; import { AcuMateContext } from "../../plugin-context"; import { getBaseScreenDocument, - isCustomizationSelectorAttribute, queryBaseScreenElements, BaseScreenDocument, - getCustomizationSelectorAttributes, - loadHtmlDocument, getScreenDocumentDisplayName, } from "../../services/screen-html-service"; import { createSuppressionEngine, SuppressionEngine } from "../../diagnostics/suppression"; +import { + HtmlIncludeFieldContext, + HtmlIncludeTemplateFieldContextCache, + forEachCustomizationSelector, + getIncludeFieldContext, + hasTemplateCustomizationSelector, + hasTemplateExpression, + resolveHtmlField, +} from "../../services/html-field-context-service"; // The validator turns the TypeScript model into CollectedClassInfo entries for every PXScreen/PXView // and then uses that metadata when validating the HTML DOM. @@ -49,18 +53,8 @@ const idOptionalTags = new Set([ "qp-wiki-tooltip", ]); -interface IncludeTemplateFieldValidationContext { - classInfoMap: Map; - screenClasses: CollectedClassInfo[]; - templateDocument?: BaseScreenDocument; - viewResolutionCache: Map; -} - -interface IncludeFieldValidationContext extends IncludeTemplateFieldValidationContext { - parameterValues: Map; -} - -type IncludeTemplateFieldValidationCache = Map; +type IncludeFieldValidationContext = HtmlIncludeFieldContext; +type IncludeTemplateFieldValidationCache = HtmlIncludeTemplateFieldContextCache; function pushHtmlDiagnostic( diagnostics: vscode.Diagnostic[], @@ -178,6 +172,11 @@ function validateDom( const hasScreenMetadata = screenClasses.length > 0; const canValidateActions = classProperties.length > 0; const viewResolutionCache = new Map(); + const hostFieldMetadataContext = { + classInfoLookup: classInfoMap, + screenClasses, + viewResolutionCache, + }; // Screen classes contain PXView and PXViewCollection properties. We cache resolutions so // repeated use of the same view name does not require scanning every screen class again. @@ -196,72 +195,13 @@ function validateDom( } function getIncludeFieldValidationContext(node: any): IncludeFieldValidationContext | undefined { - const includeUrl = node.attribs?.url; - if (typeof includeUrl !== "string" || !includeUrl.length || hasTemplateExpression(includeUrl)) { - return undefined; - } - - const includeHtmlPath = resolveIncludeFilePath(includeUrl, htmlFilePath, workspaceRoots); - if (!includeHtmlPath) { - return undefined; - } - - const normalizedIncludePath = path.normalize(includeHtmlPath); - const hostTsFilePaths = getRelatedTsFiles(htmlFilePath); - const cacheKey = [ - normalizedIncludePath, - ...hostTsFilePaths.map((filePath) => path.normalize(filePath)), - ].join("|"); - let templateContext = includeFieldContextCache.get(cacheKey); - if (!includeFieldContextCache.has(cacheKey)) { - templateContext = loadIncludeTemplateFieldValidationContext(normalizedIncludePath, hostTsFilePaths); - includeFieldContextCache.set(cacheKey, templateContext); - } - - if (!templateContext) { - return undefined; - } - - return { - ...templateContext, - parameterValues: getIncludeParameterValues(node), - }; - } - - function loadIncludeTemplateFieldValidationContext( - includeHtmlPath: string, - hostTsFilePaths: string[] - ): IncludeTemplateFieldValidationContext | undefined { - const includeTsFilePaths = getRelatedTsFiles(includeHtmlPath); - const combinedTsFilePaths = dedupeFilePaths([...hostTsFilePaths, ...includeTsFilePaths]); - const includeClassProperties = combinedTsFilePaths.length - ? loadClassInfosFromFiles(combinedTsFilePaths) - : []; - const includeRelevantClassInfos = filterClassesBySource(includeClassProperties, includeTsFilePaths); - const screenClasses = filterScreenLikeClasses(includeRelevantClassInfos); - const templateDocument = loadHtmlDocument(includeHtmlPath); - - if (!screenClasses.length && !templateDocument) { - return undefined; - } - - return { - classInfoMap: createClassInfoLookup(includeClassProperties), - screenClasses, - templateDocument, - viewResolutionCache: new Map(), - }; - } - - function getIncludeParameterValues(node: any): Map { - const values = new Map(); - const attributes = node.attribs ?? {}; - for (const [attributeName, attributeValue] of Object.entries(attributes)) { - if (typeof attributeValue === "string") { - values.set(attributeName, attributeValue); - } - } - return values; + return getIncludeFieldContext({ + documentPath: htmlFilePath, + includeNode: node, + hostTsFilePaths: getRelatedTsFiles(htmlFilePath), + workspaceRoots, + cache: includeFieldContextCache, + }); } // Custom validation logic goes here @@ -426,51 +366,52 @@ function validateDom( node.name === "field" && node.attribs.name ) { - const viewSpecified = node.attribs.name.includes("."); - const [viewFromNameAttribute, fieldFromNameAttribute] = viewSpecified ? node.attribs.name.split(".") : []; - const isUnboundField = Object.prototype.hasOwnProperty.call(node.attribs, "unbound"); if (!isUnboundField) { - let viewName = viewSpecified ? viewFromNameAttribute : findParentViewName(node); - let includeViewNameAllowsAnyViewFallback = false; - if (!viewName) { - viewName = getViewNameFromCustomizationSelectors(node); - } - if (!viewName && hasTemplateCustomizationSelector(node)) { + const hostIncludeSelectorResolution = includeFieldContext + ? resolveHtmlField({ + rawFieldName: node.attribs.name, + elementNode: node, + metadataContext: hostFieldMetadataContext, + selectorDocument: includeFieldContext.templateDocument, + }) + : undefined; + const hostBaseResolution = resolveHtmlField({ + rawFieldName: node.attribs.name, + elementNode: node, + metadataContext: hostFieldMetadataContext, + selectorDocument: baseScreenDocument, + }); + const hostResolution = hostIncludeSelectorResolution?.fieldProperty + ? hostIncludeSelectorResolution + : hostBaseResolution; + if (hostResolution?.hasTemplatedBinding || (!hostResolution?.viewName && hasTemplateCustomizationSelector(node))) { return; } - if (!viewName && includeFieldContext) { - const includeViewName = getViewNameFromIncludeCustomizationSelectors(node, includeFieldContext); - includeViewNameAllowsAnyViewFallback = hasTemplateExpression(includeViewName); - viewName = includeViewName; - } - - if (includeFieldContext) { - includeViewNameAllowsAnyViewFallback ||= hasTemplateExpression(viewName); - viewName = resolveIncludeTemplateValue(viewName, includeFieldContext); - } - const rawFieldName = viewSpecified ? fieldFromNameAttribute : node.attribs.name; - const fieldName = includeFieldContext - ? resolveIncludeTemplateValue(rawFieldName, includeFieldContext) - : rawFieldName; - if (hasTemplateExpression(viewName) || hasTemplateExpression(fieldName)) { + const includeResolution = !hostResolution?.fieldProperty && includeFieldContext + ? resolveHtmlField({ + rawFieldName: node.attribs.name, + elementNode: node, + metadataContext: includeFieldContext, + selectorDocument: includeFieldContext.templateDocument, + parameterValues: includeFieldContext.parameterValues, + allowAnyViewWhenUnscoped: true, + useParentView: false, + }) + : undefined; + if (includeResolution?.hasTemplatedBinding) { return; } - const viewResolution = resolveView(viewName); - const viewClass = viewResolution?.viewClass; - const fieldProperty = viewClass?.properties.get(fieldName); + + const fieldResolution = hostResolution?.fieldProperty ? hostResolution : includeResolution ?? hostResolution; const isValidField = - fieldProperty?.kind === "field" || - isFieldDefinedInIncludeContext( - fieldName, - viewName, - includeFieldContext, - includeViewNameAllowsAnyViewFallback - ); + fieldResolution?.fieldProperty?.kind === "field"; if (!isValidField) { const range = getRange(content, node); + const viewName = fieldResolution?.viewName; + const fieldName = fieldResolution?.fieldName ?? node.attribs.name; pushHtmlDiagnostic( diagnostics, suppression, @@ -592,178 +533,6 @@ function validateDom( return documents; } - function getViewNameFromCustomizationSelectors(node: any): string | undefined { - if (!baseScreenDocument) { - return undefined; - } - - let selectorViewName: string | undefined; - forEachCustomizationSelector(node, (_attributeName, _rawValue, normalizedValue) => { - if (selectorViewName) { - return; - } - - if (hasTemplateExpression(normalizedValue)) { - return; - } - - const { nodes, error } = queryBaseScreenElements(baseScreenDocument, normalizedValue); - if (error || !nodes.length) { - return; - } - - for (const target of nodes) { - const candidateViewName = findViewNameAtOrAbove(target); - if (candidateViewName) { - selectorViewName = candidateViewName; - return; - } - } - }); - - return selectorViewName; - } - - function getViewNameFromIncludeCustomizationSelectors( - node: any, - context: IncludeFieldValidationContext - ): string | undefined { - const includeDocument = context.templateDocument; - if (!includeDocument) { - return undefined; - } - - let selectorViewName: string | undefined; - forEachCustomizationSelector(node, (_attributeName, _rawValue, normalizedValue) => { - if (selectorViewName) { - return; - } - - if (hasTemplateExpression(normalizedValue)) { - return; - } - - const { nodes, error } = queryBaseScreenElements(includeDocument, normalizedValue); - if (error || !nodes.length) { - return; - } - - for (const target of nodes) { - const candidateViewName = findViewNameAtOrAbove(target); - if (candidateViewName) { - selectorViewName = candidateViewName; - return; - } - } - }); - - return selectorViewName; - } - - function isFieldDefinedInIncludeContext( - fieldName: string | undefined, - viewName: string | undefined, - context: IncludeFieldValidationContext | undefined, - allowAnyViewFallback = false - ): boolean { - if (!context || !fieldName || hasTemplateExpression(fieldName)) { - return false; - } - - const normalizedViewName = viewName?.trim(); - if (normalizedViewName && !hasTemplateExpression(normalizedViewName)) { - const viewResolution = resolveIncludeView(normalizedViewName, context); - const fieldProperty = viewResolution?.viewClass?.properties.get(fieldName); - if (fieldProperty?.kind === "field") { - return true; - } - - return allowAnyViewFallback ? isFieldDefinedInAnyIncludeView(fieldName, context) : false; - } - - return isFieldDefinedInAnyIncludeView(fieldName, context); - } - - function resolveIncludeView( - viewName: string, - context: IncludeFieldValidationContext - ): ViewResolution | undefined { - if (context.viewResolutionCache.has(viewName)) { - return context.viewResolutionCache.get(viewName); - } - - const resolution = resolveViewBinding(viewName, context.screenClasses, context.classInfoMap); - context.viewResolutionCache.set(viewName, resolution); - return resolution; - } - - function isFieldDefinedInAnyIncludeView( - fieldName: string, - context: IncludeFieldValidationContext - ): boolean { - for (const screenClass of context.screenClasses) { - for (const property of screenClass.properties.values()) { - if (property.kind !== "view" && property.kind !== "viewCollection") { - continue; - } - - const viewClass = resolveClassInfoForProperty(property, context.classInfoMap); - const fieldProperty = viewClass?.properties.get(fieldName); - if (fieldProperty?.kind === "field") { - return true; - } - } - } - - return false; - } - - function resolveIncludeTemplateValue( - value: string | undefined, - context: IncludeFieldValidationContext - ): string | undefined { - if (!value) { - return value; - } - - return value.replace(/{{\s*([^}\s]+)\s*}}/g, (match, parameterName: string) => { - const parameterValue = context.parameterValues.get(parameterName)?.trim(); - return parameterValue || match; - }).trim(); - } - - 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); - } - } - - function hasTemplateCustomizationSelector(node: any): boolean { - let hasTemplateSelector = false; - forEachCustomizationSelector(node, (_attributeName, _rawValue, normalizedValue) => { - if (hasTemplateExpression(normalizedValue)) { - hasTemplateSelector = true; - } - }); - return hasTemplateSelector; - } - function validateConfigBinding(bindingValue: string, node: any) { const trimmed = bindingValue.trim(); if (!trimmed.startsWith("{")) { @@ -989,24 +758,6 @@ function shouldIgnoreIncludeAttribute(attributeName: string): boolean { return false; } -function hasTemplateExpression(value: string | undefined): boolean { - return typeof value === "string" && /{{\s*[^}]+\s*}}/.test(value); -} - -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; -} - function getAttributeValueRange( content: string, node: any, From 100207a12498b1227a888adc96cb2d0b3742af9c Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Wed, 13 May 2026 12:47:33 +0200 Subject: [PATCH 18/21] Accept views with unresolved imported bases --- .../html/TestViewBindingImportedBase.html | 8 ++++++++ .../html/TestViewBindingImportedBase.ts | 12 +++++++++++ .../src/test/suite/htmlValidation.test.ts | 11 ++++++++++ .../htmlValidation/html-validation.ts | 20 +++++++++---------- 4 files changed, 41 insertions(+), 10 deletions(-) create mode 100644 acumate-plugin/src/test/fixtures/html/TestViewBindingImportedBase.html create mode 100644 acumate-plugin/src/test/fixtures/html/TestViewBindingImportedBase.ts diff --git a/acumate-plugin/src/test/fixtures/html/TestViewBindingImportedBase.html b/acumate-plugin/src/test/fixtures/html/TestViewBindingImportedBase.html new file mode 100644 index 0000000..7559610 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestViewBindingImportedBase.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/acumate-plugin/src/test/fixtures/html/TestViewBindingImportedBase.ts b/acumate-plugin/src/test/fixtures/html/TestViewBindingImportedBase.ts new file mode 100644 index 0000000..32f7f5d --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestViewBindingImportedBase.ts @@ -0,0 +1,12 @@ +declare class PXScreen {} +declare class PXFieldState {} +declare function createSingle(view: unknown): unknown; +declare class ImportedContactBase {} + +export class TestViewBindingImportedBase extends PXScreen { + ImportedContact = createSingle(ImportedContact); +} + +export class ImportedContact extends ImportedContactBase { + CustomField!: PXFieldState; +} diff --git a/acumate-plugin/src/test/suite/htmlValidation.test.ts b/acumate-plugin/src/test/suite/htmlValidation.test.ts index a3917e2..84bdd25 100644 --- a/acumate-plugin/src/test/suite/htmlValidation.test.ts +++ b/acumate-plugin/src/test/suite/htmlValidation.test.ts @@ -97,6 +97,17 @@ describe('HTML validation diagnostics', () => { assert.ok(diagnostics.some(d => d.message.includes('')), 'Expected invalid using view diagnostic'); }); + it('accepts view bindings whose view class extends an unresolved imported base', async () => { + const document = await openFixtureDocument('TestViewBindingImportedBase.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.strictEqual( + diagnostics.filter(d => d.message.includes('must be bound to a valid view') || d.message.includes('must reference a valid view')).length, + 0, + 'Expected no view diagnostics when createSingle points to a local class with an unresolved imported base' + ); + }); + it('accepts actions declared on the current using view', async () => { const document = await openFixtureDocument('TestScreenUsing.html'); await validateHtmlFile(document); diff --git a/acumate-plugin/src/validation/htmlValidation/html-validation.ts b/acumate-plugin/src/validation/htmlValidation/html-validation.ts index 06819b6..6c961f6 100644 --- a/acumate-plugin/src/validation/htmlValidation/html-validation.ts +++ b/acumate-plugin/src/validation/htmlValidation/html-validation.ts @@ -194,6 +194,14 @@ function validateDom( return resolution; } + function isValidSingleViewBinding(viewResolution: ViewResolution | undefined): boolean { + if (!viewResolution || viewResolution.property.kind !== "view" || !viewResolution.property.viewClassName) { + return false; + } + + return !viewResolution.viewClass || viewResolution.viewClass.type === "PXView" || viewResolution.viewClass.type === undefined; + } + function getIncludeFieldValidationContext(node: any): IncludeFieldValidationContext | undefined { return getIncludeFieldContext({ documentPath: htmlFilePath, @@ -239,11 +247,7 @@ function validateDom( ) { const viewName = node.attribs[`view.bind`]; const viewResolution = resolveView(viewName); - const hasValidView = - viewResolution && - viewResolution.property.viewClassName && - viewResolution.viewClass && - viewResolution.viewClass.type === "PXView"; + const hasValidView = isValidSingleViewBinding(viewResolution); if (!hasValidView) { const range = getRange(content, node); @@ -282,11 +286,7 @@ function validateDom( ) { const viewName = node.attribs.view; const viewResolution = resolveView(viewName); - const hasValidView = - viewResolution && - viewResolution.property.viewClassName && - viewResolution.viewClass && - viewResolution.viewClass.type === "PXView"; + const hasValidView = isValidSingleViewBinding(viewResolution); if (!hasValidView) { const range = getRange(content, node); From b96ab235d5e8c4886d1e9d4280a703edb8891d36 Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Wed, 13 May 2026 12:58:47 +0200 Subject: [PATCH 19/21] Treat non-button state bindings as fields --- .../src/providers/html-completion-provider.ts | 5 +- .../src/providers/html-definition-provider.ts | 32 +++++++--- .../src/providers/html-hover-provider.ts | 20 +++++- acumate-plugin/src/providers/html-shared.ts | 4 ++ .../fixtures/html/TestStateFieldBinding.html | 5 ++ .../fixtures/html/TestStateFieldBinding.ts | 12 ++++ .../html/TestStateFieldBindingInvalid.html | 3 + .../html/TestStateFieldBindingInvalid.ts | 7 +++ .../src/test/suite/htmlProviders.test.ts | 62 +++++++++++++++++++ .../src/test/suite/htmlValidation.test.ts | 21 +++++++ .../htmlValidation/html-validation.ts | 57 ++++++++++++++++- 11 files changed, 215 insertions(+), 13 deletions(-) create mode 100644 acumate-plugin/src/test/fixtures/html/TestStateFieldBinding.html create mode 100644 acumate-plugin/src/test/fixtures/html/TestStateFieldBinding.ts create mode 100644 acumate-plugin/src/test/fixtures/html/TestStateFieldBindingInvalid.html create mode 100644 acumate-plugin/src/test/fixtures/html/TestStateFieldBindingInvalid.ts diff --git a/acumate-plugin/src/providers/html-completion-provider.ts b/acumate-plugin/src/providers/html-completion-provider.ts index 4e05e51..b018296 100644 --- a/acumate-plugin/src/providers/html-completion-provider.ts +++ b/acumate-plugin/src/providers/html-completion-provider.ts @@ -15,6 +15,7 @@ import { elevateToElementNode, getAttributeContext, findParentViewName, + isActionStateBindTag, } from './html-shared'; import { ClientControlMetadata, @@ -123,7 +124,9 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider { } if (attributeContext.attributeName === 'state.bind') { - return this.createActionCompletions(screenClasses, classInfoLookup, elementNode); + return isActionStateBindTag(attributeContext.tagName) + ? this.createActionCompletions(screenClasses, classInfoLookup, elementNode) + : this.createControlStateCompletions(attributeContext.value, screenClasses, classInfoLookup); } if (attributeContext.attributeName === 'control-state.bind' && attributeContext.tagName === 'qp-field') { diff --git a/acumate-plugin/src/providers/html-definition-provider.ts b/acumate-plugin/src/providers/html-definition-provider.ts index 6df806b..e8a9334 100644 --- a/acumate-plugin/src/providers/html-definition-provider.ts +++ b/acumate-plugin/src/providers/html-definition-provider.ts @@ -16,6 +16,7 @@ import { elevateToElementNode, getAttributeContext, findParentViewName, + isActionStateBindTag, } from './html-shared'; import { resolveIncludeFilePath } from '../services/include-service'; import { @@ -178,13 +179,28 @@ export class HtmlDefinitionProvider implements vscode.DefinitionProvider { } if (attributeContext.attributeName === 'state.bind') { - const actionProperty = - findActionProperty(attributeContext.value, screenClasses) ?? - findViewActionProperty(attributeContext.value, elementNode, documentMetadataContext); - if (!actionProperty) { - return; + if (isActionStateBindTag(attributeContext.tagName)) { + const actionProperty = + 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') { @@ -262,7 +278,8 @@ function getFieldDefinitionLocations( metadataContext: DefinitionMetadataContext, selectorDocument?: BaseScreenDocument, parameterValues?: Map, - allowAnyViewFallback = false + allowAnyViewFallback = false, + useParentView = true ): vscode.Location[] { if (!rawFieldName) { return []; @@ -276,6 +293,7 @@ function getFieldDefinitionLocations( selectorDocument, parameterValues, allowAnyViewFallback, + useParentView, }); if (!resolution || resolution.hasTemplatedBinding) { return []; diff --git a/acumate-plugin/src/providers/html-hover-provider.ts b/acumate-plugin/src/providers/html-hover-provider.ts index 5b0cac6..8cac7f4 100644 --- a/acumate-plugin/src/providers/html-hover-provider.ts +++ b/acumate-plugin/src/providers/html-hover-provider.ts @@ -5,6 +5,7 @@ import { elevateToElementNode, getAttributeContext, HtmlAttributeContext, + isActionStateBindTag, } from './html-shared'; import { getRelatedTsFiles } from '../utils'; import { loadBackendFieldsForView } from './html-backend-utils'; @@ -52,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 { @@ -68,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) { @@ -110,6 +122,7 @@ async function buildFieldHover( elementNode, metadataContext: hostContext, selectorDocument: includeContext.templateDocument, + useParentView: options.useParentView, }) : undefined; const hostBaseResolution = resolveHtmlField({ @@ -117,6 +130,7 @@ async function buildFieldHover( elementNode, metadataContext: hostContext, selectorDocument: getBaseScreenDocument(htmlFilePath), + useParentView: options.useParentView, }); const hostResolution = hostIncludeSelectorResolution?.viewResolution ? hostIncludeSelectorResolution diff --git a/acumate-plugin/src/providers/html-shared.ts b/acumate-plugin/src/providers/html-shared.ts index f235679..ac917c3 100644 --- a/acumate-plugin/src/providers/html-shared.ts +++ b/acumate-plugin/src/providers/html-shared.ts @@ -274,3 +274,7 @@ export function findViewNameAtOrAbove(node: any): string | undefined { 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/test/fixtures/html/TestStateFieldBinding.html b/acumate-plugin/src/test/fixtures/html/TestStateFieldBinding.html new file mode 100644 index 0000000..a33d883 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestStateFieldBinding.html @@ -0,0 +1,5 @@ + + + + + diff --git a/acumate-plugin/src/test/fixtures/html/TestStateFieldBinding.ts b/acumate-plugin/src/test/fixtures/html/TestStateFieldBinding.ts new file mode 100644 index 0000000..1678a8b --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestStateFieldBinding.ts @@ -0,0 +1,12 @@ +@graphInfo({ + graphType: "PX.SM.ProjectNewUiFrontendFileMaintenance", + primaryView: "EstimateRecordSelected", +}) +export class TestStateFieldBinding extends PXScreen { + EstimateRecordSelected = createSingle(EstimateRecordSelected); + SaveAction!: PXActionState; +} + +export class EstimateRecordSelected extends PXView { + ImageUrl!: PXFieldState; +} diff --git a/acumate-plugin/src/test/fixtures/html/TestStateFieldBindingInvalid.html b/acumate-plugin/src/test/fixtures/html/TestStateFieldBindingInvalid.html new file mode 100644 index 0000000..60ee926 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestStateFieldBindingInvalid.html @@ -0,0 +1,3 @@ + + + diff --git a/acumate-plugin/src/test/fixtures/html/TestStateFieldBindingInvalid.ts b/acumate-plugin/src/test/fixtures/html/TestStateFieldBindingInvalid.ts new file mode 100644 index 0000000..573c69e --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestStateFieldBindingInvalid.ts @@ -0,0 +1,7 @@ +export class TestStateFieldBindingInvalid extends PXScreen { + EstimateRecordSelected = createSingle(EstimateRecordSelectedInvalid); +} + +export class EstimateRecordSelectedInvalid extends PXView { + ImageUrl!: PXFieldState; +} diff --git a/acumate-plugin/src/test/suite/htmlProviders.test.ts b/acumate-plugin/src/test/suite/htmlProviders.test.ts index 577cc84..3b499f3 100644 --- a/acumate-plugin/src/test/suite/htmlProviders.test.ts +++ b/acumate-plugin/src/test/suite/htmlProviders.test.ts @@ -24,6 +24,7 @@ const importedFixturePath = path.join(fixturesRoot, 'TestScreenImported.html'); const configCompletionPath = path.join(fixturesRoot, 'TestConfigBindingCompletion.html'); const controlTypeCompletionPath = path.join(fixturesRoot, 'TestControlTypeCompletion.html'); const duplicateViewNamesFixturePath = path.join(fixturesRoot, 'TestDuplicateViewNames.html'); +const stateFieldBindingPath = path.join(fixturesRoot, 'TestStateFieldBinding.html'); const screenFixturesRoot = path.resolve(__dirname, '../../../src/test/fixtures/screens'); const screenExtensionHtmlPath = path.join( screenFixturesRoot, @@ -272,6 +273,39 @@ describe('HTML completion provider integration', () => { assert.ok(/addItemParameters/.test(value), 'Hover should show include template view name'); }); + it('shows backend metadata for non-button state.bind field bindings', async () => { + const graphStructure: GraphStructure = { + name: backendGraphName, + views: { + EstimateRecordSelected: { + name: 'EstimateRecordSelected', + fields: { + ImageUrl: { + name: 'ImageUrl', + displayName: 'Estimate Image', + typeName: 'System.String' + } + } + } + } + }; + AcuMateContext.ApiService = new HtmlMockApiClient({ [backendGraphName]: graphStructure }); + + const document = await vscode.workspace.openTextDocument(stateFieldBindingPath); + const caret = positionAt( + document, + 'state.bind="EstimateRecordSelected.ImageUrl"', + 'state.bind="EstimateRecordSelected.'.length + 1 + ); + const hover = await provideHtmlFieldHover(document, caret); + assert.ok(hover, 'Expected hover result for state.bind field'); + const contents = Array.isArray(hover!.contents) ? hover!.contents : [hover!.contents]; + const first = contents[0]; + const value = first instanceof vscode.MarkdownString ? first.value : `${first}`; + assert.ok(/Estimate Image/.test(value), 'Hover should show state.bind backend display name'); + assert.ok(/EstimateRecordSelected/.test(value), 'Hover should show state.bind view name'); + }); + it('suggests view names for using view attribute', async () => { const document = await vscode.workspace.openTextDocument(usingFixturePath); const provider = new HtmlCompletionProvider(); @@ -365,6 +399,17 @@ describe('HTML completion provider integration', () => { assert.ok(labels.includes('ConfigureEntry'), 'ConfigureEntry not suggested from using view'); }); + it('suggests view + field pairs for non-button state.bind attributes', async () => { + const document = await vscode.workspace.openTextDocument(stateFieldBindingPath); + const provider = new HtmlCompletionProvider(); + const caret = positionAt(document, 'state.bind=""', 'state.bind="'.length); + const completions = await provider.provideCompletionItems(document, caret); + assert.ok(completions && completions.length > 0, 'No completions returned for non-button state.bind'); + const labels = completions.map(item => item.label); + assert.ok(labels.includes('EstimateRecordSelected.ImageUrl'), 'EstimateRecordSelected.ImageUrl not suggested'); + assert.ok(!labels.includes('SaveAction'), 'Non-button state.bind should not suggest PXAction names'); + }); + it('suggests field names from selector target view context', async () => { const document = await vscode.workspace.openTextDocument(screenSelectorHtmlPath); const provider = new HtmlCompletionProvider(); @@ -675,6 +720,23 @@ describe('HTML definition provider integration', () => { ); }); + it('navigates from non-button state.bind attribute to PXField property', async () => { + const document = await vscode.workspace.openTextDocument(stateFieldBindingPath); + const provider = new HtmlDefinitionProvider(); + const caret = positionAt( + document, + 'state.bind="EstimateRecordSelected.ImageUrl"', + 'state.bind="EstimateRecordSelected.'.length + 1 + ); + const definition = await provider.provideDefinition(document, caret); + const locations = Array.isArray(definition) ? definition : definition ? [definition] : []; + assert.ok(locations.length >= 1, 'No definitions returned for non-button state.bind field'); + assert.ok( + locations.some(loc => loc.uri.fsPath.endsWith('TestStateFieldBinding.ts')), + 'Expected state.bind field definition inside TestStateFieldBinding.ts' + ); + }); + it('navigates from qp-include url to referenced file', async () => { const document = await vscode.workspace.openTextDocument(includeHostPath); const provider = new HtmlDefinitionProvider(); diff --git a/acumate-plugin/src/test/suite/htmlValidation.test.ts b/acumate-plugin/src/test/suite/htmlValidation.test.ts index 84bdd25..6bf2a59 100644 --- a/acumate-plugin/src/test/suite/htmlValidation.test.ts +++ b/acumate-plugin/src/test/suite/htmlValidation.test.ts @@ -184,6 +184,27 @@ describe('HTML validation diagnostics', () => { ); }); + it('accepts non-button state.bind values that reference fields', async () => { + const document = await openFixtureDocument('TestStateFieldBinding.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.strictEqual( + diagnostics.filter(d => d.message.includes('state.bind attribute')).length, + 0, + 'Expected no state.bind diagnostics for non-button field binding' + ); + }); + + it('reports non-button state.bind values that reference missing fields', async () => { + const document = await openFixtureDocument('TestStateFieldBindingInvalid.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.ok( + diagnostics.some(d => d.message.includes('state.bind attribute references unknown field "MissingImage"')), + 'Expected diagnostic for invalid non-button state.bind field binding' + ); + }); + it('accepts qp-panel ids that map to known views', async () => { const document = await openFixtureDocument('TestPanelValid.html'); await validateHtmlFile(document); diff --git a/acumate-plugin/src/validation/htmlValidation/html-validation.ts b/acumate-plugin/src/validation/htmlValidation/html-validation.ts index 6c961f6..6ba5026 100644 --- a/acumate-plugin/src/validation/htmlValidation/html-validation.ts +++ b/acumate-plugin/src/validation/htmlValidation/html-validation.ts @@ -13,7 +13,7 @@ import { parseConfigObject, filterClassesBySource, } from "../../utils"; -import { findParentViewName } from "../../providers/html-shared"; +import { findParentViewName, isActionStateBindTag } from "../../providers/html-shared"; import { getIncludeMetadata } from "../../services/include-service"; import { getScreenTemplates } from "../../services/screen-template-service"; import { getClientControlsMetadata, ClientControlMetadata } from "../../services/client-controls-service"; @@ -304,7 +304,8 @@ function validateDom( canValidateActions && typeof actionBinding === "string" && actionBinding.length && - !hasTemplateExpression(actionBinding) + !hasTemplateExpression(actionBinding) && + isActionStateBindTag(normalizedTagName) ) { const panelHasAction = panelViewContext?.properties.get(actionBinding)?.kind === "action"; const scopedViewName = findParentViewName(node); @@ -319,6 +320,15 @@ function validateDom( ); } } + else if ( + hasScreenMetadata && + typeof actionBinding === "string" && + actionBinding.length && + !hasTemplateExpression(actionBinding) && + !isActionStateBindTag(normalizedTagName) + ) { + validateStateFieldBinding(actionBinding, node); + } if (node.type === "tag" && node.name === "qp-include") { validateIncludeNode(node, diagnostics, content, htmlFilePath, workspaceRoots, suppression); @@ -670,6 +680,49 @@ function validateDom( } } + function validateStateFieldBinding(bindingValue: string, node: any) { + const fieldResolution = resolveHtmlField({ + rawFieldName: bindingValue, + elementNode: node, + metadataContext: hostFieldMetadataContext, + selectorDocument: baseScreenDocument, + useParentView: false, + }); + if (fieldResolution?.hasTemplatedBinding) { + return; + } + + const range = getRange(content, node); + if (!fieldResolution?.viewName) { + pushHtmlDiagnostic( + diagnostics, + suppression, + range, + "The state.bind attribute must use the . format for non-button controls." + ); + return; + } + + if (!fieldResolution.viewResolution) { + pushHtmlDiagnostic( + diagnostics, + suppression, + range, + `The state.bind attribute references unknown view "${fieldResolution.viewName}".` + ); + return; + } + + if (fieldResolution.fieldProperty?.kind !== "field") { + pushHtmlDiagnostic( + diagnostics, + suppression, + range, + `The state.bind attribute references unknown field "${fieldResolution.fieldName}" on view "${fieldResolution.viewName}".` + ); + } + } + function hasConfigId(node: any): boolean { const rawConfig = node.attribs?.["config.bind"]; if (typeof rawConfig !== "string" || !rawConfig.length) { From 178458f63033642f28ea78bb6be75d96e9ddc94b Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Wed, 13 May 2026 13:09:29 +0200 Subject: [PATCH 20/21] Resolve qualified button action bindings --- .../src/providers/html-completion-provider.ts | 24 +++++++++++- .../src/providers/html-definition-provider.ts | 38 +++++++++++++++++++ .../html/TestQualifiedActionBinding.html | 4 ++ .../html/TestQualifiedActionBinding.ts | 9 +++++ .../TestQualifiedActionBindingInvalid.html | 3 ++ .../html/TestQualifiedActionBindingInvalid.ts | 7 ++++ .../src/test/suite/htmlProviders.test.ts | 32 ++++++++++++++++ .../src/test/suite/htmlValidation.test.ts | 21 ++++++++++ .../htmlValidation/html-validation.ts | 36 ++++++++++++++++-- 9 files changed, 168 insertions(+), 6 deletions(-) create mode 100644 acumate-plugin/src/test/fixtures/html/TestQualifiedActionBinding.html create mode 100644 acumate-plugin/src/test/fixtures/html/TestQualifiedActionBinding.ts create mode 100644 acumate-plugin/src/test/fixtures/html/TestQualifiedActionBindingInvalid.html create mode 100644 acumate-plugin/src/test/fixtures/html/TestQualifiedActionBindingInvalid.ts diff --git a/acumate-plugin/src/providers/html-completion-provider.ts b/acumate-plugin/src/providers/html-completion-provider.ts index b018296..6c8dcf2 100644 --- a/acumate-plugin/src/providers/html-completion-provider.ts +++ b/acumate-plugin/src/providers/html-completion-provider.ts @@ -125,7 +125,7 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider { if (attributeContext.attributeName === 'state.bind') { return isActionStateBindTag(attributeContext.tagName) - ? this.createActionCompletions(screenClasses, classInfoLookup, elementNode) + ? this.createActionCompletions(screenClasses, classInfoLookup, elementNode, attributeContext.value) : this.createControlStateCompletions(attributeContext.value, screenClasses, classInfoLookup); } @@ -642,8 +642,10 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider { private createActionCompletions( screenClasses: CollectedClassInfo[], classInfoLookup: Map, - elementNode: any + elementNode: any, + currentValue?: string ): vscode.CompletionItem[] { + const normalizedPrefix = (currentValue ?? '').trim().toLowerCase(); const actionMap = collectActionProperties(screenClasses); const items: vscode.CompletionItem[] = []; const seen = new Set(); @@ -651,6 +653,9 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider { 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'; @@ -671,6 +676,21 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider { } }); + 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 e8a9334..9dfe9b8 100644 --- a/acumate-plugin/src/providers/html-definition-provider.ts +++ b/acumate-plugin/src/providers/html-definition-provider.ts @@ -181,6 +181,7 @@ export class HtmlDefinitionProvider implements vscode.DefinitionProvider { if (attributeContext.attributeName === 'state.bind') { if (isActionStateBindTag(attributeContext.tagName)) { const actionProperty = + findQualifiedViewActionProperty(attributeContext.value, documentMetadataContext) ?? findActionProperty(attributeContext.value, screenClasses) ?? findViewActionProperty(attributeContext.value, elementNode, documentMetadataContext); if (!actionProperty) { @@ -336,6 +337,43 @@ function findViewActionProperty( 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; diff --git a/acumate-plugin/src/test/fixtures/html/TestQualifiedActionBinding.html b/acumate-plugin/src/test/fixtures/html/TestQualifiedActionBinding.html new file mode 100644 index 0000000..b59fb7b --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestQualifiedActionBinding.html @@ -0,0 +1,4 @@ + + + + diff --git a/acumate-plugin/src/test/fixtures/html/TestQualifiedActionBinding.ts b/acumate-plugin/src/test/fixtures/html/TestQualifiedActionBinding.ts new file mode 100644 index 0000000..c6dd7c6 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestQualifiedActionBinding.ts @@ -0,0 +1,9 @@ +export class TestQualifiedActionBinding extends PXScreen { + Document = createSingle(QualifiedActionDocument); + SaveAction!: PXActionState; +} + +export class QualifiedActionDocument extends PXView { + AdjustDocAmt!: PXActionState; + ImageUrl!: PXFieldState; +} diff --git a/acumate-plugin/src/test/fixtures/html/TestQualifiedActionBindingInvalid.html b/acumate-plugin/src/test/fixtures/html/TestQualifiedActionBindingInvalid.html new file mode 100644 index 0000000..89a4436 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestQualifiedActionBindingInvalid.html @@ -0,0 +1,3 @@ + + + diff --git a/acumate-plugin/src/test/fixtures/html/TestQualifiedActionBindingInvalid.ts b/acumate-plugin/src/test/fixtures/html/TestQualifiedActionBindingInvalid.ts new file mode 100644 index 0000000..8c990ca --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestQualifiedActionBindingInvalid.ts @@ -0,0 +1,7 @@ +export class TestQualifiedActionBindingInvalid extends PXScreen { + Document = createSingle(QualifiedActionDocumentInvalid); +} + +export class QualifiedActionDocumentInvalid extends PXView { + AdjustDocAmt!: PXActionState; +} diff --git a/acumate-plugin/src/test/suite/htmlProviders.test.ts b/acumate-plugin/src/test/suite/htmlProviders.test.ts index 3b499f3..ae3b824 100644 --- a/acumate-plugin/src/test/suite/htmlProviders.test.ts +++ b/acumate-plugin/src/test/suite/htmlProviders.test.ts @@ -25,6 +25,7 @@ const configCompletionPath = path.join(fixturesRoot, 'TestConfigBindingCompletio const controlTypeCompletionPath = path.join(fixturesRoot, 'TestControlTypeCompletion.html'); const duplicateViewNamesFixturePath = path.join(fixturesRoot, 'TestDuplicateViewNames.html'); const stateFieldBindingPath = path.join(fixturesRoot, 'TestStateFieldBinding.html'); +const qualifiedActionBindingPath = path.join(fixturesRoot, 'TestQualifiedActionBinding.html'); const screenFixturesRoot = path.resolve(__dirname, '../../../src/test/fixtures/screens'); const screenExtensionHtmlPath = path.join( screenFixturesRoot, @@ -399,6 +400,20 @@ describe('HTML completion provider integration', () => { assert.ok(labels.includes('ConfigureEntry'), 'ConfigureEntry not suggested from using view'); }); + it('suggests qualified view action names for qp-button state.bind attributes', async () => { + const document = await vscode.workspace.openTextDocument(qualifiedActionBindingPath); + const provider = new HtmlCompletionProvider(); + const caret = positionAt( + document, + 'state.bind="Document.AdjustDocAmt"', + 'state.bind="Document.'.length + ); + const completions = await provider.provideCompletionItems(document, caret); + assert.ok(completions && completions.length > 0, 'No completions returned for qualified action state.bind'); + const labels = completions.map(item => item.label); + assert.ok(labels.includes('Document.AdjustDocAmt'), 'Document.AdjustDocAmt not suggested'); + }); + it('suggests view + field pairs for non-button state.bind attributes', async () => { const document = await vscode.workspace.openTextDocument(stateFieldBindingPath); const provider = new HtmlCompletionProvider(); @@ -720,6 +735,23 @@ describe('HTML definition provider integration', () => { ); }); + it('navigates from qualified qp-button state.bind attribute to view PXAction definition', async () => { + const document = await vscode.workspace.openTextDocument(qualifiedActionBindingPath); + const provider = new HtmlDefinitionProvider(); + const caret = positionAt( + document, + 'state.bind="Document.AdjustDocAmt"', + 'state.bind="Document.'.length + 1 + ); + const definition = await provider.provideDefinition(document, caret); + const locations = Array.isArray(definition) ? definition : definition ? [definition] : []; + assert.ok(locations.length >= 1, 'No definitions returned for qualified view action'); + assert.ok( + locations.some(loc => loc.uri.fsPath.endsWith('TestQualifiedActionBinding.ts')), + 'Expected qualified view action definition inside TestQualifiedActionBinding.ts' + ); + }); + it('navigates from non-button state.bind attribute to PXField property', async () => { const document = await vscode.workspace.openTextDocument(stateFieldBindingPath); const provider = new HtmlDefinitionProvider(); diff --git a/acumate-plugin/src/test/suite/htmlValidation.test.ts b/acumate-plugin/src/test/suite/htmlValidation.test.ts index 6bf2a59..2402dd4 100644 --- a/acumate-plugin/src/test/suite/htmlValidation.test.ts +++ b/acumate-plugin/src/test/suite/htmlValidation.test.ts @@ -184,6 +184,27 @@ describe('HTML validation diagnostics', () => { ); }); + it('accepts qualified view action references in qp-button state.bind attributes', async () => { + const document = await openFixtureDocument('TestQualifiedActionBinding.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.strictEqual( + diagnostics.filter(d => d.message.includes('state.bind attribute must reference a valid PXAction')).length, + 0, + 'Expected no PXAction diagnostics for qualified view action binding' + ); + }); + + it('reports missing qualified view action references in qp-button state.bind attributes', async () => { + const document = await openFixtureDocument('TestQualifiedActionBindingInvalid.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.ok( + diagnostics.some(d => d.message.includes('state.bind attribute must reference a valid PXAction')), + 'Expected PXAction diagnostic for invalid qualified view action binding' + ); + }); + it('accepts non-button state.bind values that reference fields', async () => { const document = await openFixtureDocument('TestStateFieldBinding.html'); await validateHtmlFile(document); diff --git a/acumate-plugin/src/validation/htmlValidation/html-validation.ts b/acumate-plugin/src/validation/htmlValidation/html-validation.ts index 6ba5026..994fd71 100644 --- a/acumate-plugin/src/validation/htmlValidation/html-validation.ts +++ b/acumate-plugin/src/validation/htmlValidation/html-validation.ts @@ -307,10 +307,7 @@ function validateDom( !hasTemplateExpression(actionBinding) && isActionStateBindTag(normalizedTagName) ) { - const panelHasAction = panelViewContext?.properties.get(actionBinding)?.kind === "action"; - const scopedViewName = findParentViewName(node); - const viewHasAction = resolveView(scopedViewName)?.viewClass?.properties.get(actionBinding)?.kind === "action"; - if (!actionLookup.has(actionBinding) && !panelHasAction && !viewHasAction) { + if (!resolveActionBinding(actionBinding, node)) { const range = getRange(content, node); pushHtmlDiagnostic( diagnostics, @@ -531,6 +528,18 @@ function validateDom( }); } + function resolveActionBinding(actionBinding: string, node: any): boolean { + const qualifiedAction = parseQualifiedActionBinding(actionBinding); + if (qualifiedAction) { + return resolveView(qualifiedAction.viewName)?.viewClass?.properties.get(qualifiedAction.actionName)?.kind === "action"; + } + + const panelHasAction = panelViewContext?.properties.get(actionBinding)?.kind === "action"; + const scopedViewName = findParentViewName(node); + const viewHasAction = resolveView(scopedViewName)?.viewClass?.properties.get(actionBinding)?.kind === "action"; + return actionLookup.has(actionBinding) || panelHasAction || viewHasAction; + } + function getSelectorValidationDocuments(): BaseScreenDocument[] { const documents: BaseScreenDocument[] = []; const includeDocument = includeFieldContext?.templateDocument; @@ -811,6 +820,25 @@ function shouldIgnoreIncludeAttribute(attributeName: string): boolean { return false; } +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 getAttributeValueRange( content: string, node: any, From 81360baf41de39960ca465d37cdfd508dc04ea90 Mon Sep 17 00:00:00 2001 From: "alexander.nesvizhsky" Date: Wed, 13 May 2026 13:16:04 +0200 Subject: [PATCH 21/21] Resolve include selectors with parameters --- .../services/html-field-context-service.ts | 9 +++- .../src/services/screen-html-service.ts | 43 ++++++++++++++++++- .../TestParameterizedIncludeSelectors.html | 9 ++++ ...tParameterizedIncludeSelectorsInvalid.html | 9 ++++ .../src/test/suite/htmlValidation.test.ts | 21 +++++++++ 5 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 acumate-plugin/src/test/fixtures/html/TestParameterizedIncludeSelectors.html create mode 100644 acumate-plugin/src/test/fixtures/html/TestParameterizedIncludeSelectorsInvalid.html diff --git a/acumate-plugin/src/services/html-field-context-service.ts b/acumate-plugin/src/services/html-field-context-service.ts index d533b16..889ed07 100644 --- a/acumate-plugin/src/services/html-field-context-service.ts +++ b/acumate-plugin/src/services/html-field-context-service.ts @@ -16,6 +16,7 @@ import { findParentViewName, findViewNameAtOrAbove } from '../providers/html-sha import { resolveIncludeFilePath } from './include-service'; import { BaseScreenDocument, + createParameterizedHtmlDocument, getCustomizationSelectorAttributes, isCustomizationSelectorAttribute, loadHtmlDocument, @@ -342,11 +343,17 @@ export function getIncludeFieldContext(options: { return undefined; } + const parameterValues = getIncludeParameterValues(includeNode); + const templateDocument = templateContext.templateDocument + ? createParameterizedHtmlDocument(templateContext.templateDocument, parameterValues) + : undefined; + return { ...templateContext, + templateDocument, includeNode, includePath: normalizedIncludePath, - parameterValues: getIncludeParameterValues(includeNode), + parameterValues, hostScreenClasses: options.hostScreenClasses, }; } diff --git a/acumate-plugin/src/services/screen-html-service.ts b/acumate-plugin/src/services/screen-html-service.ts index ba3e6b2..d0cc4f3 100644 --- a/acumate-plugin/src/services/screen-html-service.ts +++ b/acumate-plugin/src/services/screen-html-service.ts @@ -23,7 +23,7 @@ export interface SelectorQueryResult { error?: string; } -const customizationSelectorAttributes = ["before", "after", "append", "prepend", "prepand", "move", "remove"] as const; +const customizationSelectorAttributes = ["modify", "before", "after", "append", "prepend", "prepand", "move", "remove"] as const; const customizationSelectorAttributeSet = new Set( customizationSelectorAttributes.map(attribute => attribute.toLowerCase()) ); @@ -134,6 +134,20 @@ export function queryBaseScreenElements(document: BaseScreenDocument, selector: } } +export function createParameterizedHtmlDocument( + document: BaseScreenDocument, + parameterValues: Map +): BaseScreenDocument { + const dom = parseHtml(document.content); + applyTemplateParametersToNodes(dom, parameterValues); + + return { + filePath: document.filePath, + content: document.content, + dom, + }; +} + export function getDocumentForNode( document: BaseScreenDocument, node: any @@ -173,6 +187,33 @@ function parseHtml(content: string): any[] { return domTree; } +function applyTemplateParametersToNodes( + nodes: any[] | undefined, + parameterValues: Map +) { + if (!nodes) { + return; + } + + for (const node of nodes) { + if (node?.attribs) { + for (const [attributeName, attributeValue] of Object.entries(node.attribs)) { + if (typeof attributeValue === "string") { + node.attribs[attributeName] = applyTemplateParameters(attributeValue, parameterValues); + } + } + } + + applyTemplateParametersToNodes(node?.children, parameterValues); + } +} + +function applyTemplateParameters(value: string, parameterValues: Map): string { + return value.replace(/{{\s*([^#\/^}\s]+)\s*}}/g, (match, parameterName: string) => ( + parameterValues.has(parameterName) ? parameterValues.get(parameterName) ?? "" : match + )); +} + function tryGetMtime(targetPath: string): number | undefined { try { return fs.statSync(targetPath).mtimeMs; diff --git a/acumate-plugin/src/test/fixtures/html/TestParameterizedIncludeSelectors.html b/acumate-plugin/src/test/fixtures/html/TestParameterizedIncludeSelectors.html new file mode 100644 index 0000000..4c016b3 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestParameterizedIncludeSelectors.html @@ -0,0 +1,9 @@ + + + + + diff --git a/acumate-plugin/src/test/fixtures/html/TestParameterizedIncludeSelectorsInvalid.html b/acumate-plugin/src/test/fixtures/html/TestParameterizedIncludeSelectorsInvalid.html new file mode 100644 index 0000000..a66e7ba --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestParameterizedIncludeSelectorsInvalid.html @@ -0,0 +1,9 @@ + + + + + diff --git a/acumate-plugin/src/test/suite/htmlValidation.test.ts b/acumate-plugin/src/test/suite/htmlValidation.test.ts index 2402dd4..0a4fb6f 100644 --- a/acumate-plugin/src/test/suite/htmlValidation.test.ts +++ b/acumate-plugin/src/test/suite/htmlValidation.test.ts @@ -357,6 +357,27 @@ describe('HTML validation diagnostics', () => { ); }); + it('validates qp-include child selectors after applying include parameters', async () => { + const document = await openFixtureDocument('TestParameterizedIncludeSelectors.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.strictEqual( + diagnostics.filter(d => d.message.includes('selector')).length, + 0, + 'Expected no selector diagnostics for parameterized qp-include selectors' + ); + }); + + it('reports invalid qp-include modify selectors after applying include parameters', async () => { + const document = await openFixtureDocument('TestParameterizedIncludeSelectorsInvalid.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.ok( + diagnostics.some(d => d.message.includes('modify selector') && d.message.includes('MissingEmail')), + 'Expected diagnostic for invalid parameterized qp-include modify selector' + ); + }); + it('accepts qp-template name values defined by ScreenTemplates', async () => { const document = await openFixtureDocument('TestQpTemplate.html'); await validateHtmlFile(document);