blob: be8318baa7775735749c823b9be76632dde4befc [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Utility Functions for Language Service Providers
*
* This module provides common utility functions used across all completion and hover providers
* for DFDL and TDML language support. Key functionality includes:
*
* - Position and context detection: Determining cursor context within XML documents
* - Element and attribute parsing: Extracting names and ranges from document text
* - XML parsing and navigation: Working with XML DOM structures
* - Completion item creation: Building VS Code completion items with appropriate styling
* - XPath detection: Identifying when cursor is within an XPath expression
*
* These utilities are shared between DFDL and TDML providers to ensure consistent behavior
* and reduce code duplication.
*/
import * as vscode from 'vscode'
import { isXPath } from '../semantics/dfdlExt'
import { xml2js } from 'xml-js'
// Regular expression to match XML schema elements with optional namespace prefixes
// Matches patterns like: <schema>, </schema>, <xs:schema>, <xsd:schema>
const schemaPrefixRegEx = new RegExp('</?(|[^ ]+:)schema')
/**
* List of high-level DFDL schema element names.
* These are the primary structural elements that can appear in a DFDL schema.
* Used for element completion suggestions and validation.
*/
const items = [
'element',
'sequence',
'choice',
'group',
'simpleType',
'complexType',
'annotation',
'appinfo',
'assert',
'discriminator',
'defineFormat',
'format',
'newVariableInstance',
'defineVariable',
'setVariable',
'defineEscapeScheme',
'escapeScheme',
'dfdl:element',
'dfdl:sequence',
'dfdl:simpleType',
'restriction',
'schema',
'xml version',
'include',
'import',
]
/**
* Represents an XML element with its name, namespace, and attributes.
* Used to encapsulate parsed XML element information for provider logic.
*/
export class XmlItem {
private _itemName: string = 'none'
private _itemNS: string = 'none'
private _itemAttributes: string[] = []
/** Gets the XML element name (e.g., "element", "sequence") */
public get itemName() {
return this._itemName
}
/** Sets the XML element name */
public set itemName(name: string) {
this._itemName = name
}
/** Gets the namespace prefix (e.g., "xs", "dfdl") */
public get itemNS() {
return this._itemNS
}
/** Sets the namespace prefix */
public set itemNS(nameSpace: string) {
this._itemNS = nameSpace
}
/** Sets the list of attribute names for this element */
public set itemAttributes(attributeNames: string[]) {
this._itemAttributes = attributeNames
}
/** Gets the list of attribute names for this element */
public get itemAttributes() {
return this._itemAttributes
}
}
/**
* Returns the list of high-level DFDL element names.
* @returns Array of element name strings
*/
export function getItems() {
return items
}
/**
* Retrieves the namespace prefix used for XML Schema elements in the document.
* Looks at the beginning of the document to find schema declarations.
*
* @param document - The VS Code text document
* @returns The namespace prefix (e.g., "xs", "xsd") or default empty string
*/
export function getSchemaNsPrefix(document: vscode.TextDocument) {
const pos = new vscode.Position(0, 0)
return getNsPrefix(document, pos)
}
/**
* Default namespace prefix when no explicit prefix is found.
* Empty string represents the default (unprefixed) namespace.
*/
export const defaultXsdNsPrefix = ''
/**
* DFDL namespace prefix used for DFDL-specific format elements
* in otherwise standard XML Schema elements.
*/
export const dfdlDefaultPrefix = 'dfdl:'
/**
* Inserts a snippet into the active text editor at the specified position.
* Used to programmatically insert text templates with tab stops and placeholders.
*
* @param snippetString - The snippet text with VS Code snippet syntax ($1, $2, etc.)
* @param backpos - The position where the snippet should be inserted
*/
export function insertSnippet(snippetString: string, backpos: vscode.Position) {
vscode.window.activeTextEditor?.insertSnippet(
new vscode.SnippetString(snippetString),
backpos
)
}
/**
* Counts the number of lines from the current position back to the opening tag of a specified element.
* Searches backwards through the document to find the nearest opening tag that matches the given tag name.
* Skips closing tags and self-closing tags.
*
* @param document - The VS Code text document
* @param position - The current cursor position
* @param tag - The element tag name to search for (without namespace prefix)
* @returns The number of lines between the position and the opening tag, or -1 if not found
*/
export function lineCount(
document: vscode.TextDocument,
position: vscode.Position,
tag: string
) {
let lineNum = position.line
let lineCount = 0
let nsPrefix = getNsPrefix(document, position)
// Search backwards through the document
while (lineNum !== 0) {
--lineNum
++lineCount
const triggerText = document.lineAt(lineNum).text
// Check if this line contains the opening tag (not a closing tag or self-closing tag)
if (
triggerText.includes('<' + nsPrefix + tag) &&
!triggerText.includes('</' + nsPrefix + tag) &&
!triggerText.includes('/>')
) {
return lineCount
}
}
return lineCount
}
/**
* Finds the nearest open (unclosed) XML element at the cursor position.
* Searches through the list of known DFDL elements to determine which element
* the cursor is currently inside of. This is used to provide context-aware completions.
*
* @param document - The VS Code text document
* @param position - The current cursor position
* @returns XmlItem containing the element name, namespace, and attributes, or empty XmlItem if none found
*/
export function nearestOpen(
document: vscode.TextDocument,
position: vscode.Position
) {
let xmlItem = new XmlItem()
// Early return for empty document
if (document.lineCount === 1 && position.character === 0) {
return xmlItem
}
const nsPrefix = getNsPrefix(document, position)
// Check each known DFDL element to see if the cursor is inside it
for (let i = 0; i < items.length; ++i) {
let [isTagOpen, isDfdlNs, attributeNames] = checkTagOpen(
document,
position,
nsPrefix,
items[i]
)
if (isTagOpen) {
// Set the appropriate namespace (DFDL-specific or schema default)
if (isDfdlNs) {
xmlItem.itemNS = 'dfdl:'
} else {
xmlItem.itemNS = nsPrefix
}
xmlItem.itemName = items[i]
xmlItem.itemAttributes = attributeNames
return xmlItem
}
}
return xmlItem
}
/**
* Finds the nearest matching opening tag for a set of known "items" relative to a given document position.
*
* The function searches backwards from a provided starting line/column to locate an opening tag that matches
* one of the known items (the `items` array referenced in the implementation) with the requested namespace prefix.
* It returns a tuple describing the found tag name (without namespace prefix), the line index where it was found,
* and the character index of the tag's opening '<' on that line.
*
* Search behavior:
* - If the trigger line contains multiple items and the start position lies inside the same line's tag range,
* the function searches backward within the trigger line for the previous '<' and attempts to match a tag there.
* - Otherwise, the function walks upward line-by-line from `startLine` (adjusting if `startLine` equals the
* trigger line and the cursor is exactly at a '<' or the trigger line is blank), skipping self-closing tags (`/>`),
* and checking each line for an opening tag that matches one of the known items.
* - Before checking a line it ignores lines containing closing-tag syntax (`</`) unless the cursor is within
* the tag's inner content and both opening and closing tags appear on the same line; in that case the
* function may return that tag if the cursor lies between the `>` and `</`.
* - The namespace prefix is recalculated from the document/position during the upward search (via `getNsPrefix`).
*
* Edge cases & return value:
* - If a match is found, returns `[itemName, lineIndex, charIndex]` where:
* - itemName is the matched item string (without the namespace prefix),
* - lineIndex is a 0-based line number where the opening '<' appears,
* - charIndex is the 0-based character index of that opening '<' on the line.
* - If no matching opening tag is found, returns `['none', 0, 0]`.
*
* Notes:
* - The function depends on external helpers and data: `items` (array of valid tag names),
* `getItemsOnLineCount(text)`, and `getNsPrefix(document, position)`.
* - Indices and positions are zero-based (compatible with `vscode.TextDocument` / `vscode.Position`).
*
* @param document - The TextDocument in which to search.
* @param position - The trigger Position (usually the cursor position) used for namespace inference and context.
* @param nsPrefix - The current namespace prefix to use when matching tags (may be recalculated during search).
* @param startLine - The 0-based line index from which to begin the backward search.
* @param startPos - The 0-based character index on `startLine` from which to begin searching (used for same-line logic).
* @returns A tuple of `[matchedItemName, lineIndex, charIndex]`, or `['none', 0, 0]` if nothing is found.
*/
export function nearestTag(
document: vscode.TextDocument,
position: vscode.Position,
nsPrefix: string,
startLine: number,
startPos: number
): [string, number, number] {
const triggerLine = position.line
let lineNum = startLine
const triggerText = document.lineAt(triggerLine).text
const itemsOnLine = getItemsOnLineCount(document.lineAt(lineNum).text)
let tagPos = triggerText.indexOf('<')
let endPos = triggerText.lastIndexOf('>')
if (
itemsOnLine > 1 &&
startPos !== tagPos &&
startPos < endPos &&
endPos != startPos
) {
let textBeforeTrigger = triggerText.substring(0, startPos)
let prevTagPos = 0
while (prevTagPos > -1) {
prevTagPos = textBeforeTrigger.lastIndexOf('<')
let tag = textBeforeTrigger.substring(prevTagPos)
if (
!textBeforeTrigger.includes('</') &&
!textBeforeTrigger.includes('/>')
) {
for (let i = 0; i < items.length; ++i) {
if (tag.includes('<' + nsPrefix + items[i])) {
return [items[i], startLine, prevTagPos]
}
}
}
textBeforeTrigger = textBeforeTrigger.substring(0, prevTagPos)
}
} else {
if (
startLine === triggerLine &&
(tagPos === startPos || triggerText.trim() === '')
) {
--lineNum
}
while (lineNum > -1 && lineNum < document.lineCount) {
let currentText = document.lineAt(lineNum).text
if (getItemsOnLineCount(currentText) < 2) {
if (!currentText.includes('/>')) {
for (let i = 0; i < items.length; ++i) {
nsPrefix = getNsPrefix(document, position)
if (
!currentText.includes('</') &&
(currentText.includes('<' + nsPrefix + items[i]) ||
(lineNum === 0 && currentText.includes(items[i])))
) {
tagPos = currentText.lastIndexOf('<')
return [items[i], lineNum, tagPos]
}
if (
currentText.includes('<' + nsPrefix + items[i]) &&
currentText.includes('</' + nsPrefix + items[i]) &&
position.character > currentText.indexOf('>') &&
position.character <= currentText.indexOf('</')
) {
tagPos = currentText.lastIndexOf('<')
return [items[i], lineNum, tagPos]
}
}
}
}
--lineNum
}
}
return ['none', 0, 0]
}
/**
* Determines whether the XML/DFDL start tag for a given namespace prefix and tag name
* is currently "open" at the provided document position, and returns any attribute
* names found on that open tag.
*
* The function inspects the current and preceding lines to locate the nearest opening
* tag that matches "<{nsPrefix}{tag}" (falls back to "dfdl:" prefix if necessary).
* It handles both single-line tags and multi-line start tags:
* - For single-line tags it verifies the cursor position falls within the tag's
* attribute region (between the start '<...' and the closing '>' if present).
* - For multi-line tags (start tag is present but its '>' is not on the same line),
* it defers to checkMultiLineTag to determine openness across multiple lines.
*
* Behavior summary:
* - Scans backward when the current line contains few XML items to find the line
* that actually contains the tag start.
* - Detects and normalizes a DFDL-specific 'dfdl:' prefix when the provided prefix
* does not match but a 'dfdl:' occurrence is found.
* - If the tag is considered open at the given position, collects attribute names
* by calling getAttributeNames(document, position, nsPrefix, tag).
*
* Notes and side effects:
* - The nsPrefix parameter is reassigned locally if a 'dfdl:' fallback is detected;
* this does not mutate any caller-owned object but affects subsequent internal checks.
* - Relies on helper functions getItemsOnLineCount, getAttributeNames and
* checkMultiLineTag as well as vscode.TextDocument.lineAt for text access.
* - Returns quickly if the tag cannot be located or the position is outside the tag.
*
* @param document - The vscode.TextDocument to inspect.
* @param position - The vscode.Position (cursor) used to determine whether the tag is open.
* @param nsPrefix - Expected namespace prefix (e.g. "dfdl:" or custom prefix) to match before the tag name.
* @param tag - The tag name to search for (without angle brackets or prefix).
* @returns A tuple containing:
* - [0] boolean: true if the specified start tag is open at the given position, false otherwise.
* - [1] boolean: true if a 'dfdl:' prefix was detected/used instead of the provided nsPrefix.
* - [2] string[]: array of attribute names found on the open tag (empty if tag not open).
*/
export function checkTagOpen(
document: vscode.TextDocument,
position: vscode.Position,
nsPrefix: string,
tag: string
): [boolean, boolean, string[]] {
let triggerLine = position.line
let triggerText = document.lineAt(triggerLine).text
let itemsOnLine = getItemsOnLineCount(triggerText)
let isMultiLineTag = false
let isDfdlPrefix = false
let origTriggerLine = triggerLine
let compareText = triggerText
let compareLine = triggerLine
let attributeNames: string[] = []
const triggerPos = position.character
while (itemsOnLine < 2 && !triggerText.trim().startsWith('<')) {
triggerText = document.lineAt(--triggerLine).text
}
let tagPos = triggerText.lastIndexOf('<' + nsPrefix + tag)
if (tagPos < 0) {
tagPos = triggerText.lastIndexOf('<dfdl:' + tag)
if (tagPos > 0) {
isDfdlPrefix = true
nsPrefix = 'dfdl:'
}
}
// If there is one or less xml tags on the line and the beginning of the line
// contains the start tag and there is not an ending tag character after the
// start tag assume it is a multi-line tag
if (
itemsOnLine < 2 &&
!document
.lineAt(triggerLine + 1)
.text.trim()
.startsWith('<') &&
triggerText.trim().startsWith('<' + nsPrefix + tag) &&
triggerText.indexOf('>', tagPos) < 0 //if tag ending character is missing will return -1
) {
isMultiLineTag = true
}
if (!isMultiLineTag) {
const nextTagPos = triggerText.indexOf('<', tagPos + 1)
let tagEndPos = triggerText.indexOf('>', tagPos)
if (tagPos > -1 && itemsOnLine >= 1) {
if (
triggerPos > tagPos &&
((triggerPos <= tagEndPos &&
(nextTagPos > tagEndPos || nextTagPos === -1)) ||
tagEndPos === -1)
) {
attributeNames = getAttributeNames(document, position, nsPrefix, tag)
return [true, isDfdlPrefix, attributeNames]
}
}
while (compareText.trim() === '') {
compareText = document.lineAt(--compareLine).text
}
tagPos = triggerText.indexOf('<' + nsPrefix + tag)
if (tagPos < 0) {
tagPos = triggerText.lastIndexOf('<dfdl:' + tag)
if (tagPos > 0) {
isDfdlPrefix = true
nsPrefix = 'dfdl:'
}
}
if (itemsOnLine < 2 && tagPos > -1) {
if (triggerText !== compareText) {
tagEndPos = compareText.indexOf('>')
}
if (
(triggerPos > tagPos &&
triggerPos <= tagEndPos &&
triggerLine === position.line) ||
(compareLine == position.line &&
triggerPos <= tagEndPos &&
triggerPos > tagPos) ||
position.line < origTriggerLine
) {
attributeNames = getAttributeNames(document, position, nsPrefix, tag)
return [true, isDfdlPrefix, attributeNames]
}
}
}
if (!isMultiLineTag || tagPos === -1) {
return [false, isDfdlPrefix, attributeNames]
}
//if this tag is part of a multi line set of annotations return true
//else this tag is not open return false
return checkMultiLineTag(
document,
position,
itemsOnLine,
nsPrefix,
tagPos,
triggerLine,
tag
)
}
/**
* Determine the namespace prefix to use for a given item name.
*
* Uses a set of string-matching rules (checked in order) to compute the
* prefix. If no rule matches, the provided `nsPrefix` is returned unchanged.
*
* @param item - The item name to evaluate. Matching is case-sensitive.
* @param nsPrefix - The default namespace prefix to return when no rule applies.
*
* @returns The computed prefix. Possible values include:
* - the original `nsPrefix` (default)
* - `'dfdl:'` for certain DFDL-related items
* - `'?'` specifically for `"xml version"`
* - `''` (empty string) for fully-qualified DFDL element names
*
* @remarks
* Rules (applied in order; later rules override earlier ones):
* 1. Default: return `nsPrefix`.
* 2. If `item` is one of `'assert'`, `'discriminator'`, `'defineFormat'`, `'format'`,
* or if `item` contains `'Variable'` or `'scape'`, set prefix to `'dfdl:'`.
* 3. If `item === 'xml version'`, set prefix to `'?'`.
* 4. If `item` is one of `'dfdl:element'`, `'dfdl:sequence'`, `'dfdl:simpleType'`,
* or `'dfdl:format'`, set prefix to the empty string `''`.
*
* Note that because rules are evaluated sequentially, a later rule can override
* an earlier one. For example, `'dfdl:format'` will result in `''` even though
* it contains `'format'` and would match the earlier `'format'` rule.
*
* @example
* getItemPrefix('assert', 'ns:') // returns 'dfdl:'
* @example
* getItemPrefix('xml version', 'ns:') // returns '?'
* @example
* getItemPrefix('dfdl:element', 'ns:') // returns ''
* @example
* getItemPrefix('otherItem', 'ns:') // returns 'ns:'
*/
export function getItemPrefix(item: string, nsPrefix: string) {
let itemPrefix = nsPrefix
if (
item === 'assert' ||
item === 'discriminator' ||
item === 'defineFormat' ||
item === 'format' ||
item.includes('Variable') ||
item.includes('scape')
) {
itemPrefix = 'dfdl:'
}
if (item === 'xml version') {
itemPrefix = '?'
}
if (
item === 'dfdl:element' ||
item === 'dfdl:sequence' ||
item === 'dfdl:simpleType' ||
item === 'dfdl:format'
) {
itemPrefix = ''
}
return itemPrefix
}
/**
* Determine whether the cursor is located within a multi-line opening tag for a given namespaced tag,
* and collect attribute names if the opening tag spans multiple lines.
*
* The function:
* - Treats the tag as a DFDL tag if nsPrefix === 'dfdl:' (the returned isDfdlTag will be true).
* - Immediately returns [false, isDfdlTag, []] when more than one item exists on the current line (itemsOnLine > 1).
* - If the current line is blank, searches upward until a non-blank line is found.
* - If the cursor appears after a closing tag ("</...") or on a self-closing tag ("/>") on that line, returns false.
* - Searches upward from the non-blank line to find an opening tag that matches "<" + nsPrefix + tag.
* - When an opening tag line is found, concatenates following lines until a '>' is encountered (or document end).
* - If both the opening tag and the '>' are present in the concatenated text, calls getAttributeNames(...) to
* collect attribute names and returns [true, isDfdlTag, attributeNames].
* - Otherwise returns [false, isDfdlTag, []].
*
* Notes and edge cases:
* - tagPos and tagLine are accepted by the signature but are not used by the function logic.
* - The search upward stops at the top of the document (openTagLine > 0) and the forward search for '>' stops
* at document.lineCount.
* - The function never mutates the provided TextDocument or Position; it only reads lines.
* - getAttributeNames is invoked only when an opening tag and a closing '>' for the start tag are found;
* attributeNames is an empty array otherwise.
*
* @param document - The vscode.TextDocument to inspect.
* @param position - The current cursor position within the document.
* @param itemsOnLine - Number of syntactic items on the current line; if > 1 the function exits early.
* @param nsPrefix - Namespace prefix string (e.g. "dfdl:" or ""), used when matching the tag name.
* @param tagPos - (Unused) numeric position of the tag in the original call site; preserved for compatibility.
* @param tagLine - (Unused) original line index of the tag; preserved for compatibility.
* @param tag - The tag name (without prefix) to search for (e.g. "sequence", "element").
* @param isDfdlTag - Optional input flag; when nsPrefix === "dfdl:" this will be set to true and returned.
*
* @returns A tuple:
* - [0] boolean: true when a multi-line opening tag for the given namespaced tag was found and closed with '>',
* - [1] boolean: isDfdlTag flag indicating whether the tag is a DFDL tag,
* - [2] string[]: an array of attribute names extracted from the multi-line tag (empty if none or not multi-line).
*/
export function checkMultiLineTag(
document: vscode.TextDocument,
position: vscode.Position,
itemsOnLine: number,
nsPrefix: string,
tagPos: number,
tagLine: number,
tag: string,
isDfdlTag = false
): [boolean, boolean, string[]] {
if (nsPrefix === 'dfdl:') {
isDfdlTag = true
}
let attributeNames: string[] = []
if (itemsOnLine > 1) {
return [false, isDfdlTag, attributeNames]
}
let triggerLine = position.line
let currentLine = position.line
let openTagLine = position.line
let closeTagLine = position.line
let origText = document.lineAt(currentLine).text
//if the original line is blank get the previous line
while (origText.trim() === '') {
origText = document.lineAt(--currentLine).text
}
//If the cursor is after an end tag return false
if (
(position.character > origText.indexOf('</') ||
position.line > currentLine) &&
(origText.includes('</' + nsPrefix + tag) || origText.includes('/>'))
) {
return [false, isDfdlTag, attributeNames]
}
let currentText = origText
//Get the opening tag
while (
(currentText.trim() === '' ||
!currentText.includes('<' + nsPrefix + tag)) &&
openTagLine > 0
) {
--openTagLine
currentText = document.lineAt(openTagLine).text
}
if (currentText.includes('<' + nsPrefix + tag)) {
let multiLineText = currentText.trim()
let closeText = document.lineAt(openTagLine).text
closeTagLine = openTagLine
//Get closing tag
while (
(closeText.trim() === '' || !closeText.includes('>')) &&
closeTagLine < document.lineCount
) {
++closeTagLine
closeText = document.lineAt(closeTagLine).text
multiLineText += ' ' + closeText.trim()
}
if (closeText.includes('>')) {
multiLineText += closeText.trim()
}
currentText = multiLineText
if (
currentText.includes('<' + nsPrefix + tag) &&
currentText.includes('>') &&
closeTagLine >= triggerLine
) {
attributeNames = getAttributeNames(document, position, nsPrefix, tag)
return [true, isDfdlTag, attributeNames]
}
}
return [false, isDfdlTag, attributeNames]
}
//returns an empty value or a prefix plus a colon
/**
* Get the XML namespace prefix used for the XSD/schema in the given document at or above the supplied position.
*
* Scans lines starting from the top of the document down to the line of `position` (inclusive) and returns the
* first capture group produced by `schemaPrefixRegEx`. The captured value may be an empty string or a prefix
* including a trailing colon (e.g. "xsd:"). If no matching schema declaration is found (or if `position.line`
* is 0), the function returns `defaultXsdNsPrefix`.
*
* @param document - The VS Code text document to search for a schema namespace prefix.
* @param position - The position whose line number is used as the inclusive upper bound for the search.
* @returns The namespace prefix string (possibly empty or including a colon) if found; otherwise `defaultXsdNsPrefix`.
*
* @remarks
* - This function relies on the module-level `schemaPrefixRegEx` to identify schema prefix declarations
* and on `defaultXsdNsPrefix` as the fallback value.
* - The search proceeds from the start of the document to the given line and returns the first match encountered.
*
* @example
* // If a line near the top contains 'xmlns:xsd="http://www.w3.org/2001/XMLSchema"', this may return 'xsd:'.
*/
export function getNsPrefix(
document: vscode.TextDocument,
position: vscode.Position
) {
let initialLineNum = position.line
let lineNum = 0
while (initialLineNum !== 0 && lineNum <= initialLineNum) {
const lineText = document.lineAt(lineNum).text
// returns either empty prefix value or a prefix plus a colon
let text = lineText.match(schemaPrefixRegEx)
if (text != null) {
return text[1]
}
++lineNum
}
//returns the standard prefix plus a colon in the case of missing schema tag
return defaultXsdNsPrefix
}
/**
* Collects the attribute names of an XML/HTML tag located at the given position in a VS Code text document.
*
* The function handles both single-line tags and tags that span multiple lines. If the tag at the
* supplied position is not a complete single-line tag, the function searches upward to find the
* opening tag line and then reads forward until it encounters a line containing `>` to assemble the
* full tag text. The assembled text is parsed and the attribute names are returned as a unique list.
*
* @param document - The vscode.TextDocument to read lines from.
* @param position - The vscode.Position indicating the starting line to inspect.
* @param nsPrefix - Namespace prefix expected before the tag name (e.g. `"ns:"` or `""`).
* @param tag - The tag name to locate (without angle brackets).
* @returns An array of unique attribute names found on the tag. Returns an empty array if no attributes are found or the tag cannot be parsed.
*
* @remarks
* - The search for the opening tag moves upward from the provided position until a line containing the
* prefixed tag is found or the start of the document is reached.
* - Once the opening tag line is found, subsequent lines are appended until a closing `>` is encountered,
* producing a single string that is parsed to extract attributes.
* - The function uses a parser to extract attributes and returns the attribute keys; duplicate names are
* de-duplicated.
*
* @example
* // Single-line tag
* // <ns:tag attr1="a" attr2="b">
* // getAttributeNames(document, position, "ns:", "tag") -> ["attr1", "attr2"]
*
* @example
* // Multi-line tag
* // <ns:tag
* // attr1="a"
* // attr2="b"
* // >
* // getAttributeNames(document, position, "ns:", "tag") -> ["attr1", "attr2"]
*/
export function getAttributeNames(
document: vscode.TextDocument,
position: vscode.Position,
nsPrefix: string,
tag: string
): string[] {
let currentLine = position.line
let openTagLine = position.line
let closeTagLine = position.line
let currentText = document.lineAt(currentLine).text
let closeText = currentText
//if multi-line tag
if (
currentText.trim().startsWith('<' + nsPrefix + tag) &&
!currentText.endsWith('>') &&
!document
.lineAt(currentLine + 1)
.text.trim()
.startsWith('<')
) {
//Get the opening tag
while (
(currentText.trim() === '' ||
!currentText.includes('<' + nsPrefix + tag)) &&
openTagLine > -1
) {
--openTagLine
currentText = document.lineAt(openTagLine).text
}
let multiLineText = currentText.trim()
closeTagLine = openTagLine
//Get closing tag
closeText = document.lineAt(openTagLine).text
while (
(closeText.trim() === '' || !closeText.includes('>')) &&
closeTagLine < document.lineCount
) {
++closeTagLine
closeText = document.lineAt(closeTagLine).text
multiLineText += ' ' + closeText.trim()
}
currentText = multiLineText
}
let attributeNames: string[] = []
if (currentText.includes('>')) {
const xmljs = xml2js(currentText, {})
const attributes = xmljs.elements?.[0].attributes
if (attributes) {
const attributeSet: Set<string> = new Set(Object.keys(attributes))
attributeNames = [...attributeSet]
return attributeNames
}
}
return attributeNames
}
/**
* Count opening tag-like items on a single line of text.
*
* Scans the provided line for '<' ... '>' pairs and counts occurrences that
* look like opening tags. The function ignores known non-opening constructs
* such as explicit closing tags, HTML/XML comments, and certain bracketed
* constructs. If an unmatched '<' (without a corresponding '>') is found,
* it is counted as one item and scanning stops.
*
* Special-case:
* - If the input contains the substring "schema" but does not contain
* "schemaLocation", the function immediately returns 1.
*
* Excluded from counting (examples):
* - Constructs containing "</" (closing tags)
* - Comment markers "<!--" or "-->"
* - Constructs beginning with "<[" or "<!["
*
* @param triggerText - The line of text to analyze (expected to be a string).
* @returns The number of opening tag-like items found on the line (non-negative integer).
*
* @remarks
* - The function performs a simple, single-line scan and does not validate
* tag names or nesting structure.
* - Time complexity is linear in the length of the input string.
*
* @example
* // returns 2
* getItemsOnLineCount("<a><b>");
*
* @example
* // returns 1
* getItemsOnLineCount("<a></a>");
*
* @example
* // returns 1 (special-case for "schema")
* getItemsOnLineCount("schema");
*/
export function getItemsOnLineCount(triggerText: String) {
let itemsOnLine = 0
let nextPos = 0
let result = 0
if (
triggerText.includes('schema') &&
!triggerText.includes('schemaLocation')
) {
itemsOnLine = 1
return itemsOnLine
}
while (result != -1 && triggerText.includes('<')) {
result = triggerText.indexOf('<', nextPos)
if (result > -1) {
let endPos = triggerText.indexOf('>', nextPos)
if (endPos === -1) {
++itemsOnLine
break
}
let testForCloseTag = triggerText.substring(nextPos, endPos)
if (
!testForCloseTag.includes('</') &&
!testForCloseTag.includes('<!--') &&
!testForCloseTag.includes('-->') &&
!testForCloseTag.includes('<[') &&
!testForCloseTag.includes('<![')
) {
++itemsOnLine
}
result = nextPos
nextPos = endPos + 1
}
}
return itemsOnLine
}
/**
* Determines if the cursor is currently positioned within an XPath expression.
* When the cursor is in XPath, DFDL element/attribute intellisense should be disabled
* and XPath-specific completion should be active instead.
*
* This is used to prevent DFDL completions from showing when the user is typing
* XPath expressions in attributes like dfdl:inputValueCalc, dfdl:outputValueCalc,
* dfdl:test, etc.
*
* @param document - The VS Code text document
* @param position - The cursor position to check
* @returns true if the cursor is within an XPath expression, false otherwise
*/
export function isInXPath(
document: vscode.TextDocument,
position: vscode.Position
): boolean {
return isXPath(position)
}
/**
* Verifies whether the character immediately before the cursor position is NOT
* the expected trigger character for a completion provider.
*
* This is used by completion providers to ensure they were actually triggered
* by the correct character and not by some other mechanism (like manual invocation).
*
* @param document - The VS Code text document
* @param position - The current cursor position
* @param triggerChar - The expected trigger character (e.g., '<', '>', '/', ' ', '=')
* @returns true if the character before the cursor is NOT the trigger character, false otherwise
*/
export function isNotTriggerChar(
document: vscode.TextDocument,
position: vscode.Position,
triggerChar: string
) {
const triggerText = document.lineAt(position.line).text
const triggerPos = position.character
const trigChar = triggerText.substring(triggerPos - 1, triggerPos)
if (trigChar != triggerChar) {
return true
} else {
return false
}
}
/**
* Checks if the given trigger character indicates the end of an XML tag.
* Tag end characters are '/' (for self-closing tags) and '>' (for closing tags).
*
* This is used to determine when NOT to provide element name completions,
* since element names only make sense at the beginning of tags, not at the end.
*
* @param trigChar - The trigger character to check (typically from CompletionContext)
* @returns true if the character is '/' or '>', false otherwise
*/
export function isTagEndTrigger(trigChar: string | undefined) {
if (trigChar == '/' || trigChar == '>') {
return true
} else {
return false
}
}
/**
* Determines if the cursor is positioned immediately after an equals sign (=).
* This typically indicates the user is about to enter an attribute value.
*
* Used to suppress certain completion providers (like element completion) when
* the user is in an attribute value context and should see attribute value
* completions instead.
*
* @param document - The VS Code text document
* @param position - The current cursor position
* @returns true if the cursor is directly after an '=' character, false otherwise
*/
export function cursorAfterEquals(
document: vscode.TextDocument,
position: vscode.Position
) {
const triggerText = document.lineAt(position.line).text
const triggerPos = position.character
const textBeforeTrigger = triggerText.substring(0, triggerPos)
let currentPos = -1
if ((currentPos = textBeforeTrigger.lastIndexOf('=')) === -1) {
return false
}
if (triggerPos === currentPos + 1) {
return true
}
return false
}
/**
* Determines if the cursor is currently positioned within quoted attribute value text.
* Handles both single quotes (') and double quotes ("), and works across multiple lines
* for attribute values that span multiple lines.
*
* The function searches backwards to find the opening quote (preceded by '=') and forwards
* to find the closing quote, then checks if the cursor position falls within that range.
*
* This is crucial for:
* - Enabling attribute value completion providers
* - Disabling element/attribute name completion when inside values
* - Detecting XPath expressions within attribute values
*
* @param document - The VS Code text document
* @param position - The current cursor position
* @returns true if the cursor is inside a quoted attribute value (between opening and closing quotes),
* false otherwise
*/
export function cursorWithinQuotes(
document: vscode.TextDocument,
position: vscode.Position
) {
const quoteChar: string[] = ["'", '"']
let startLine = position.line
// Check both single and double quotes
for (let i = 0; i < quoteChar.length; ++i) {
let currentText = document.lineAt(startLine).text
// If line contains '<' but no quotes, we're not in an attribute value
if (
currentText.includes('<') &&
!currentText.includes("'") &&
!currentText.includes('"')
) {
return false
}
if (currentText.includes(quoteChar[i])) {
let textBeforeTrigger = currentText.substring(0, position.character)
// Check if cursor is before an attribute assignment
if (
currentText.indexOf('=' + quoteChar[i]) > position.character &&
textBeforeTrigger.trim() == ''
) {
return false
}
let quoteStartLine = startLine
let quoteStartPos = -1
let equalStartPos = -1
// Search backwards for the opening quote (preceded by '=')
while (
(equalStartPos = textBeforeTrigger.lastIndexOf('=' + quoteChar[i])) ===
-1
) {
if (textBeforeTrigger.indexOf('<') !== -1) {
break
}
textBeforeTrigger = document.lineAt(--quoteStartLine).text
}
quoteStartPos = equalStartPos + 1
let quoteEndLine = quoteStartLine
let quoteEndPos = -1
// Search forwards for the closing quote
if (quoteStartPos > -1) {
while (
quoteEndLine < document.lineCount &&
(quoteEndPos = currentText.indexOf(
quoteChar[i],
quoteStartPos + 1
)) === -1
) {
currentText = document.lineAt(++quoteEndLine).text
}
// Verify the opening quote is preceded by '=' and check if cursor is within the quoted range
if (
quoteEndPos > -1 &&
currentText.indexOf('=', quoteStartPos - 1) === quoteStartPos - 1
) {
if (
(position.line > quoteStartLine && position.line < quoteEndLine) ||
(quoteEndLine === quoteStartLine &&
position.character > quoteStartPos &&
position.character <= quoteEndPos) ||
(position.line === quoteStartLine &&
position.character > quoteStartPos &&
position.line < quoteEndLine) ||
(position.line === quoteEndLine &&
position.character <= quoteEndPos &&
position.line > quoteStartLine)
) {
return true
}
}
}
}
}
return false
}
/**
* Determines if the cursor is currently positioned within curly braces { }.
* Curly braces in DFDL are used for expression language contexts, where
* different completion rules apply.
*
* The function searches backwards to find the opening brace '{' and forwards
* to find the closing brace '}', handling multi-line expressions. If the cursor
* falls within this range, the function returns true.
*
* This is used to:
* - Disable XML element/attribute completion inside DFDL expressions
* - Enable expression-specific completion (variables, functions, operators)
* - Properly scope completion providers
*
* @param document - The VS Code text document
* @param position - The current cursor position
* @returns true if the cursor is inside a pair of curly braces, false otherwise
*/
export function cursorWithinBraces(
document: vscode.TextDocument,
position: vscode.Position
) {
let startLine = position.line
let currentText = document.lineAt(startLine).text
let braceStartLine = startLine
let braceStartPos = -1
// Search backwards for opening brace '{'
while (
braceStartLine > 0 &&
(braceStartPos = currentText.indexOf('{')) === -1
) {
currentText = document.lineAt(--braceStartLine).text
}
let braceEndLine = braceStartLine
let braceEndPos = -1
if (braceStartPos > -1) {
// Search forwards for closing brace '}'
while (
braceEndLine < document.lineCount &&
(braceEndPos = currentText.indexOf('}')) === -1
) {
currentText = document.lineAt(++braceEndLine).text
}
// Check if cursor position falls within the brace range
if (braceEndPos > -1) {
if (
(position.line > braceStartLine && position.line < braceEndLine) ||
(braceEndLine === braceStartLine &&
position.character > braceStartPos &&
position.character <= braceEndPos) ||
(position.line === braceStartLine &&
position.character > braceStartPos &&
position.line < braceEndLine) ||
(position.line === braceEndLine &&
position.character <= braceEndPos &&
position.line > braceStartLine)
) {
return true
}
}
}
return false
}
/**
* Checks if there is an unclosed (open) curly brace in the current context.
* This detects when a curly brace expression is incomplete and missing its
* matching closing/opening brace.
*
* The function checks two scenarios:
* 1. If current line has '{', search forward for matching '}' - if not found, brace is open
* 2. If current line has '}', search backward for matching '{' - if not found, brace is open
*
* This is used to disable certain completion providers when braces are unbalanced,
* as the expression context is ambiguous or malformed.
*
* @param document - The VS Code text document
* @param position - The current cursor position
* @returns true if there is an unclosed brace, false if braces are balanced
*/
export function checkBraceOpen(
document: vscode.TextDocument,
position: vscode.Position
) {
let lineNum = position.line
let triggerText = document.lineAt(lineNum).text
// If line contains opening brace, check if it has a closing brace
if (triggerText.includes('{')) {
while (!triggerText.includes('}') && lineNum < document.lineCount) {
triggerText = document.lineAt(++lineNum).text
}
if (!triggerText.includes('}')) {
return true // Opening brace without closing brace
}
}
// If line contains closing brace, check if it has an opening brace
if (triggerText.includes('}')) {
while (!triggerText.includes('{') && lineNum > 0) {
triggerText = document.lineAt(--lineNum).text
}
if (!triggerText.includes('{')) {
return true // Closing brace without opening brace
}
}
return false
}
/**
* Creates a VS Code completion item from intellisense data.
* Constructs a completion item with appropriate snippet text, documentation,
* and optional prefix values.
*
* The function:
* - Creates a CompletionItem with the item name as the label
* - Sets the insert text as a snippet (supporting tab stops like $1, $2, etc.)
* - Optionally prepends a prefix value (e.g., namespace prefix)
* - Attaches markdown documentation if provided
*
* Some items (like dfdl:choiceBranchKey, dfdl:simpleType) are excluded from
* receiving the prefix value as they have special formatting requirements.
*
* @param e - The intellisense item data containing:
* - item: The display name/label
* - snippetString: The VS Code snippet syntax to insert
* - markdownString: Optional documentation in markdown format
* @param preVal - Prefix value to prepend to the snippet (e.g., attribute spacing)
* @param nsPrefix - The namespace prefix (e.g., 'xs:', 'dfdl:')
* @returns A configured VS Code CompletionItem ready to be shown to the user
*/
export function createCompletionItem(
e:
| {
item: string
snippetString: string
markdownString: string
}
| {
item: string
snippetString: string
markdownString: undefined
},
preVal: string,
nsPrefix: string
) {
const completionItem = new vscode.CompletionItem(e.item)
// Items that should not receive the prefix value
const noPreVals = [
'dfdl:choiceBranchKey=',
'dfdl:representation',
'dfdl:choiceDispatchKey=',
'dfdl:simpleType',
'dfdl:element',
'restriction',
]
// Apply prefix value unless item is in the exclusion list
if (preVal !== '' && !noPreVals.includes(e.item)) {
completionItem.insertText = new vscode.SnippetString(
preVal + e.snippetString
)
} else {
completionItem.insertText = new vscode.SnippetString(e.snippetString)
}
// Attach documentation if provided
if (e.markdownString) {
completionItem.documentation = new vscode.MarkdownString(e.markdownString)
}
return completionItem
}