"use strict"; const COMPONENT_DECLARATION = /\b(component|layout)\s+([A-Za-z_$][\w$]*)\s*\{/; function findMatchingBrace(source, openingBrace) { let depth = 0; let quote = null; let escaped = false; for (let index = openingBrace; index < source.length; index += 1) { const character = source[index]; if (quote) { if (escaped) escaped = false; else if (character === "\\") escaped = true; else if (character === quote) quote = null; continue; } if (character === '"' || character === "'" || character === "`") quote = character; else if (character === "{") depth += 1; else if (character === "}" && --depth === 0) return index; } return -1; } function inferType(defaultValue) { const value = defaultValue.trim(); if (value === "undefined") return "unknown"; if (/^(?:true|false)$/.test(value)) return "boolean"; if (/^-?(?:\d+\.?\d*|\.\d+)$/.test(value)) return "number"; if (/^["'`]/.test(value)) return "string"; if (value.startsWith("[")) return "array"; if (value.startsWith("{")) return "object"; if (value === "null") return "null"; return "unknown"; } function runtimeType(type) { const value = String(type || "") .trim() .replace(/^readonly\s+/, ""); const unionParts = value.split("|").map((part) => part.trim()); const concreteParts = unionParts.filter((part) => !/^(?:null|undefined)$/.test(part)); if ( /^(?:string|String)(?:\s*\|\s*(?:null|undefined))*$/.test(value) || (concreteParts.length > 0 && concreteParts.every((part) => /^(?:"[^"]*"|'[^']*')$/.test(part))) ) return "string"; if ( /^(?:number|Number)(?:\s*\|\s*(?:null|undefined))*$/.test(value) || (concreteParts.length > 0 && concreteParts.every((part) => /^-?(?:\d+\.?\d*|\.\d+)$/.test(part))) ) return "number"; if ( /^(?:boolean|Boolean)(?:\s*\|\s*(?:null|undefined))*$/.test(value) || (concreteParts.length > 0 && concreteParts.every((part) => /^(?:true|false)$/.test(part))) ) return "boolean"; if (/^bigint(?:\s*\|\s*(?:null|undefined))*$/.test(value)) return "bigint"; if (/^(?:Array\s*<|ReadonlyArray\s*<|.+\[\])/.test(value) || /^\[/.test(value)) return "array"; if (/^(?:Record\s*<|object\b|\{)/.test(value)) return "object"; if (/=>|^Function$/.test(value)) return "function"; return value || "unknown"; } function stringLiteral(value) { const match = /^(?:"([\s\S]*)"|'([\s\S]*)'|`([\s\S]*)`)$/.exec(value.trim()); return match ? (match[1] ?? match[2] ?? match[3]) : null; } function declaredOptions(type) { const value = String(type || "").trim(); if (!value) return []; const parts = value.split("|").map((part) => part.trim()); if (!parts.every((part) => /^(?:"[^"]*"|'[^']*')$/.test(part))) return []; return parts.map((part) => part.slice(1, -1)); } function inferOptions(source, propName) { const escaped = propName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const options = new Set(); const comparisons = new RegExp(`\\b${escaped}\\s*(?:===|!==|==|!=)\\s*(["'])(.*?)\\1`, "g"); let match; while ((match = comparisons.exec(source)) !== null) options.add(match[2]); return [...options].sort(); } function parseComponentMetadata(source, uri = null) { const declaration = COMPONENT_DECLARATION.exec(source); if (!declaration) return null; const propsKeyword = /\bprops\s*\{/.exec(source.slice(declaration.index)); const props = []; const events = []; if (propsKeyword) { const start = declaration.index + propsKeyword.index; const openingBrace = source.indexOf("{", start); const closingBrace = findMatchingBrace(source, openingBrace); const bodyEnd = closingBrace === -1 ? source.length : closingBrace; const body = source.slice(openingBrace + 1, bodyEnd); const linePattern = /^(?:\s*\/\/\s*@required\s*\r?\n)?\s*([A-Za-z_$][\w$]*)(\?)?(?:\s*:\s*([^=\r\n]+?))?(?:\s*=\s*(.*?))?\s*$/gm; let propMatch; while ((propMatch = linePattern.exec(body)) !== null) { const optional = Boolean(propMatch[2]); const annotation = propMatch[3] && propMatch[3].trim(); const hasDefault = propMatch[4] !== undefined; const defaultValue = hasDefault ? propMatch[4] : "undefined"; const name = propMatch[1]; props.push({ name, defaultValue, required: !optional && (!hasDefault || defaultValue.trim() === "undefined" || /^\s*\/\/\s*@required/m.test(propMatch[0])), type: annotation || inferType(defaultValue), options: declaredOptions(annotation).length > 0 ? declaredOptions(annotation) : inferOptions(source, name), }); } const eventPattern = /^\s*@event\s+([A-Za-z_$][\w$]*)\s*=\s*function\s*$/gm; let eventMatch; while ((eventMatch = eventPattern.exec(body)) !== null) events.push(eventMatch[1]); } const outputsKeyword = /\boutputs\s*\{/.exec(source.slice(declaration.index)); if (outputsKeyword) { const start = declaration.index + outputsKeyword.index; const openingBrace = source.indexOf("{", start); const closingBrace = findMatchingBrace(source, openingBrace); const outputBody = source.slice( openingBrace + 1, closingBrace === -1 ? source.length : closingBrace, ); const outputPattern = /(?:^|\s)([A-Za-z_$][\w$]*)\s*\(/g; let outputMatch; while ((outputMatch = outputPattern.exec(outputBody)) !== null) { if (!events.includes(outputMatch[1])) events.push(outputMatch[1]); } } return { kind: declaration[1], name: declaration[2], props, events, uri }; } function unwrapAttributeValue(value) { const trimmed = value.trim(); if (trimmed.startsWith("{") && trimmed.endsWith("}")) return trimmed.slice(1, -1).trim(); return trimmed; } function isWrappedExpression(value) { const trimmed = value.trim(); return trimmed.startsWith("{") && trimmed.endsWith("}"); } function expressionLiteralType(value) { const expression = value.trim(); if (/^(?:true|false)$/.test(expression)) return "boolean"; if (/^-?(?:\d+\.?\d*|\.\d+)$/.test(expression)) return "number"; if (/^(?:"[\s\S]*"|'[\s\S]*'|`[\s\S]*`)$/.test(expression)) return "string"; if (expression.startsWith("[")) return "array"; if (expression.startsWith("{")) return "object"; if (expression === "null") return "null"; return null; } function attributeValueType(value, symbols = new Map()) { const expression = isWrappedExpression(value); const unwrapped = unwrapAttributeValue(value); if (/^[A-Za-z_$][\w$]*$/.test(unwrapped) && symbols.has(unwrapped)) { return symbols.get(unwrapped); } const literal = expressionLiteralType(unwrapped); if (literal) return literal; if (expression) { if ( /^!\s*/.test(unwrapped) || /(?:===|!==|==|!=|<=|>=|<|>|\bin\b|\binstanceof\b)/.test(unwrapped) ) { return "boolean"; } // Dynamic member access, calls, logical expressions and ternaries cannot be // proven from component metadata alone. Treat them as unknown instead of as // quoted strings so valid expressions such as `{item.external || false}` do // not produce false boolean/string diagnostics. return "unknown"; } return "string"; } function isTypeCompatible(prop, value, symbols = new Map()) { const expected = runtimeType(prop.type); if (expected === "unknown" || expected === "null") return true; const actual = runtimeType(attributeValueType(value, symbols)); if (actual === "unknown") return true; if (expected === actual) return true; if (expected === "number" && actual === "string") return Number.isFinite(Number(value)); if (expected === "boolean" && actual === "string") return /^(?:true|false|1|0|yes|no|on|off)?$/i.test(value); return false; } function parseComponentTags(source) { const tags = []; const pattern = /<([A-Z][A-Za-z0-9_$]*)(\s[\s\S]*?)?\s*\/?>/g; let match; while ((match = pattern.exec(source)) !== null) { const attributes = []; const attributeSource = match[2] || ""; const attributeOffset = match.index + match[0].indexOf(attributeSource); const attributePattern = /([^\s=/>]+)\s*=\s*(["'])([\s\S]*?)\2/g; let attributeMatch; while ((attributeMatch = attributePattern.exec(attributeSource)) !== null) { const nameStart = attributeOffset + attributeMatch.index; attributes.push({ name: attributeMatch[1], value: attributeMatch[3], nameStart, nameEnd: nameStart + attributeMatch[1].length, }); } tags.push({ name: match[1], start: match.index, end: match.index + match[0].length, nameStart: match.index + 1, nameEnd: match.index + 1 + match[1].length, attributes, }); } return tags; } function validateComponentTags(source, components) { const diagnostics = []; const own = parseComponentMetadata(source); const symbols = new Map((own?.props || []).map((prop) => [prop.name, prop.type])); const statePattern = /^\s*state\s+([A-Za-z_$][\w$]*)(?:\s*:\s*([^=\r\n]+?))?\s*=\s*(.*?)\s*$/gm; let stateMatch; while ((stateMatch = statePattern.exec(source)) !== null) { symbols.set(stateMatch[1], stateMatch[2]?.trim() || inferType(stateMatch[3])); } for (const tag of parseComponentTags(source)) { const component = components.get(tag.name); if (!component) continue; const provided = new Map(tag.attributes.map((attribute) => [attribute.name, attribute])); const declared = new Map(component.props.map((prop) => [prop.name, prop])); const declaredEvents = new Set(component.events || []); for (const prop of component.props) { if (prop.required && !provided.has(prop.name)) { diagnostics.push({ severity: "error", code: "wrn-missing-component-prop", message: `<${tag.name}> requires prop \`${prop.name}\` (${prop.type}).`, start: tag.nameStart, end: tag.nameEnd, }); } } for (const attribute of tag.attributes) { if (attribute.name.startsWith("@")) { const eventName = attribute.name.slice(1); if (!declaredEvents.has(eventName)) { diagnostics.push({ severity: "warning", code: "wrn-unknown-component-event", message: `Unknown event \`${eventName}\` on <${tag.name}>.`, start: attribute.nameStart, end: attribute.nameEnd, }); } continue; } const prop = declared.get(attribute.name); if (!prop) { diagnostics.push({ severity: "warning", code: "wrn-unknown-component-prop", message: `Unknown prop \`${attribute.name}\` on <${tag.name}>.`, start: attribute.nameStart, end: attribute.nameEnd, }); continue; } const actualType = attributeValueType(attribute.value, symbols); if (!isTypeCompatible(prop, attribute.value, symbols)) { diagnostics.push({ severity: "error", code: "wrn-component-prop-type", message: `Prop \`${attribute.name}\` on <${tag.name}> expects ${prop.type}, but received ${runtimeType(actualType)}.`, start: attribute.nameStart, end: attribute.nameEnd, }); } const optionValue = isWrappedExpression(attribute.value) ? stringLiteral(unwrapAttributeValue(attribute.value)) : (stringLiteral(attribute.value) ?? attribute.value); if (optionValue !== null && prop.options.length > 0 && !prop.options.includes(optionValue)) { diagnostics.push({ severity: "warning", code: "wrn-component-prop-option", message: `Prop \`${attribute.name}\` should be one of: ${prop.options.join(", ")}.`, start: attribute.nameStart, end: attribute.nameEnd, }); } } } return diagnostics; } module.exports = { attributeValueType, declaredOptions, inferType, runtimeType, isTypeCompatible, parseComponentMetadata, parseComponentTags, validateComponentTags, };