feat: add typed WRN declarations
This commit is contained in:
@@ -35,6 +35,20 @@ function inferType(defaultValue) {
|
||||
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;
|
||||
@@ -61,16 +75,22 @@ function parseComponentMetadata(source, uri = null) {
|
||||
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*(.*?)\s*$/gm;
|
||||
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 defaultValue = propMatch[2];
|
||||
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: defaultValue.trim() === "undefined" || /^\s*\/\/\s*@required/m.test(propMatch[0]),
|
||||
type: inferType(defaultValue),
|
||||
required:
|
||||
!hasDefault ||
|
||||
defaultValue.trim() === "undefined" ||
|
||||
/^\s*\/\/\s*@required/m.test(propMatch[0]),
|
||||
type: annotation || inferType(defaultValue),
|
||||
options: inferOptions(source, name),
|
||||
});
|
||||
}
|
||||
@@ -85,8 +105,11 @@ function unwrapAttributeValue(value) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function attributeValueType(value) {
|
||||
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";
|
||||
@@ -94,12 +117,13 @@ function attributeValueType(value) {
|
||||
return "string";
|
||||
}
|
||||
|
||||
function isTypeCompatible(prop, value) {
|
||||
if (prop.type === "unknown" || prop.type === "null") return true;
|
||||
const actual = attributeValueType(value);
|
||||
if (prop.type === actual) return true;
|
||||
if (prop.type === "number" && actual === "string") return Number.isFinite(Number(value));
|
||||
if (prop.type === "boolean" && actual === "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;
|
||||
}
|
||||
@@ -137,6 +161,13 @@ function parseComponentTags(source) {
|
||||
|
||||
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;
|
||||
@@ -167,11 +198,12 @@ function validateComponentTags(source, components) {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!isTypeCompatible(prop, attribute.value)) {
|
||||
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 ${attributeValueType(attribute.value)}.`,
|
||||
message: `Prop \`${attribute.name}\` on <${tag.name}> expects ${prop.type}, but received ${runtimeType(actualType)}.`,
|
||||
start: attribute.nameStart,
|
||||
end: attribute.nameEnd,
|
||||
});
|
||||
@@ -194,6 +226,7 @@ function validateComponentTags(source, components) {
|
||||
module.exports = {
|
||||
attributeValueType,
|
||||
inferType,
|
||||
runtimeType,
|
||||
isTypeCompatible,
|
||||
parseComponentMetadata,
|
||||
parseComponentTags,
|
||||
|
||||
Reference in New Issue
Block a user