Files
WRNexusJS/editors/vscode/src/component-metadata.js
T
2026-07-24 12:46:44 +05:30

253 lines
9.1 KiB
JavaScript

"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+/, "");
if (/^(?:string|String)(?:\s*\|\s*(?:null|undefined))*$/.test(value)) return "string";
if (/^(?:number|Number)(?:\s*\|\s*(?:null|undefined))*$/.test(value)) return "number";
if (/^(?:boolean|Boolean)(?:\s*\|\s*(?:null|undefined))*$/.test(value)) 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 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 annotation = propMatch[2] && propMatch[2].trim();
const hasDefault = propMatch[3] !== undefined;
const defaultValue = hasDefault ? propMatch[3] : "undefined";
const name = propMatch[1];
props.push({
name,
defaultValue,
required:
!hasDefault ||
defaultValue.trim() === "undefined" ||
/^\s*\/\/\s*@required/m.test(propMatch[0]),
type: annotation || inferType(defaultValue),
options: 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]);
}
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 attributeValueType(value, symbols = new Map()) {
const unwrapped = unwrapAttributeValue(value);
if (/^[A-Za-z_$][\w$]*$/.test(unwrapped) && symbols.has(unwrapped)) {
return symbols.get(unwrapped);
}
if (/^(?:true|false)$/.test(unwrapped)) return "boolean";
if (/^-?(?:\d+\.?\d*|\.\d+)$/.test(unwrapped)) return "number";
if (unwrapped.startsWith("[")) return "array";
if (unwrapped.startsWith("{")) return "object";
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 (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 literal = stringLiteral(attribute.value) ?? attribute.value;
if (prop.options.length > 0 && !prop.options.includes(literal)) {
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,
inferType,
runtimeType,
isTypeCompatible,
parseComponentMetadata,
parseComponentTags,
validateComponentTags,
};