feat: add typed WRN declarations
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.13
|
||||
|
||||
- Added explicit prop and state types, local `types` blocks, and typed function
|
||||
parameter/return highlighting and completion snippets.
|
||||
- Nested component diagnostics now resolve typed prop and state expressions.
|
||||
|
||||
## 0.2.12
|
||||
|
||||
- Added component-aware prop completions, hover tables, required-prop checks,
|
||||
|
||||
@@ -4,6 +4,29 @@ Complete VS Code language support for [WRNexus](https://wrnexusjs.dev) `.wrn` fi
|
||||
|
||||
WRNexus is an SSR-first, Bun-powered full-stack framework with reactive components, layouts, colocated APIs, browser functions, lifecycle hooks, state watchers, client-side navigation, and reusable UI components.
|
||||
|
||||
Typed declarations work across props, state, and functions:
|
||||
|
||||
```wrn
|
||||
component UserCard {
|
||||
types {
|
||||
interface User { id: string; name: string }
|
||||
}
|
||||
props {
|
||||
user: User
|
||||
compact: boolean = false
|
||||
}
|
||||
state selected: boolean = false
|
||||
functions {
|
||||
function select(user: User): void {
|
||||
selected = true
|
||||
}
|
||||
}
|
||||
view { <article>{user.name}</article> }
|
||||
}
|
||||
```
|
||||
|
||||
The extension shows declared prop types and reports missing required props and incompatible values. Typed prop and state expressions passed into nested components retain their declared type.
|
||||
|
||||
## Installation
|
||||
|
||||
Search for **WRNexus Language Support** in the VS Code Extensions view or run:
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
"folding": {
|
||||
"offSide": false,
|
||||
"markers": {
|
||||
"start": "^\\s*(?:page|component|layout|api|realtime|view|seo|props|style|functions|lifecycle|watch|ssr|client)\\b.*\\{\\s*$",
|
||||
"start": "^\\s*(?:page|component|layout|api|realtime|view|seo|types|props|style|functions|lifecycle|watch|ssr|client)\\b.*\\{\\s*$",
|
||||
"end": "^\\s*\\}\\s*$"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wrnexus",
|
||||
"version": "0.2.12",
|
||||
"version": "0.2.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wrnexus",
|
||||
"version": "0.2.12",
|
||||
"version": "0.2.13",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"devDependencies": {
|
||||
"@vscode/vsce": "^3.9.2"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "wrnexus",
|
||||
"displayName": "WRNexus Language Support",
|
||||
"description": "Complete language support for WRNexus .wrn files, including highlighting, formatting, diagnostics, snippets, lifecycle hooks, state watchers, component functions, completions, and definition navigation.",
|
||||
"version": "0.2.12",
|
||||
"version": "0.2.13",
|
||||
"publisher": "wrnexus",
|
||||
"private": true,
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
"WRN state": {
|
||||
"prefix": ["wrn-state", "state"],
|
||||
"description": "Create reactive state",
|
||||
"body": ["state ${1:name} = ${2:\"value\"}"]
|
||||
"body": ["state ${1:name}: ${2:string} = ${3:\"value\"}"]
|
||||
},
|
||||
|
||||
"WRN functions block": {
|
||||
|
||||
+219
-21
@@ -39,9 +39,12 @@ var __export = (target, all) => {
|
||||
// ../../packages/compiler/src/index.ts
|
||||
var exports_src = {};
|
||||
__export(exports_src, {
|
||||
runtimeTypeOf: () => runtimeTypeOf,
|
||||
parse: () => parse,
|
||||
inferredRuntimeType: () => inferredRuntimeType,
|
||||
generateNative: () => generateNative,
|
||||
generate: () => generate,
|
||||
eraseFunctionTypes: () => eraseFunctionTypes,
|
||||
compileWireFile: () => compileWireFile,
|
||||
compileNativeWireFile: () => compileNativeWireFile,
|
||||
compile: () => compile,
|
||||
@@ -109,6 +112,9 @@ class Lexer {
|
||||
case "=":
|
||||
this.pos++;
|
||||
return { type: "eq", value: c, pos };
|
||||
case ":":
|
||||
this.pos++;
|
||||
return { type: "colon", value: c, pos };
|
||||
case ",":
|
||||
this.pos++;
|
||||
return { type: "comma", value: c, pos };
|
||||
@@ -167,6 +173,67 @@ class Lexer {
|
||||
v += src[this.pos++];
|
||||
return v.trim();
|
||||
}
|
||||
readTypeAnnotation() {
|
||||
const { src } = this;
|
||||
let value = "";
|
||||
let angle = 0;
|
||||
let square = 0;
|
||||
let brace = 0;
|
||||
let paren = 0;
|
||||
let quote = null;
|
||||
while (this.pos < src.length) {
|
||||
const c = src[this.pos];
|
||||
if (quote) {
|
||||
value += c;
|
||||
this.pos++;
|
||||
if (c === "\\" && this.pos < src.length)
|
||||
value += src[this.pos++];
|
||||
else if (c === quote)
|
||||
quote = null;
|
||||
continue;
|
||||
}
|
||||
if (c === '"' || c === "'" || c === "`") {
|
||||
quote = c;
|
||||
value += c;
|
||||
this.pos++;
|
||||
continue;
|
||||
}
|
||||
if (c === "<")
|
||||
angle++;
|
||||
else if (c === ">" && angle > 0)
|
||||
angle--;
|
||||
else if (c === "[")
|
||||
square++;
|
||||
else if (c === "]" && square > 0)
|
||||
square--;
|
||||
else if (c === "{")
|
||||
brace++;
|
||||
else if (c === "}" && brace > 0)
|
||||
brace--;
|
||||
else if (c === "(")
|
||||
paren++;
|
||||
else if (c === ")" && paren > 0)
|
||||
paren--;
|
||||
if (angle === 0 && square === 0 && brace === 0 && paren === 0) {
|
||||
if (c === "=") {
|
||||
this.pos++;
|
||||
const type2 = value.trim();
|
||||
if (!type2)
|
||||
throw new LexError(`Expected a type annotation at offset ${this.pos}`);
|
||||
return { type: type2, hasDefault: true };
|
||||
}
|
||||
if (c === `
|
||||
` || c === "\r")
|
||||
break;
|
||||
}
|
||||
value += c;
|
||||
this.pos++;
|
||||
}
|
||||
const type = value.trim();
|
||||
if (!type)
|
||||
throw new LexError(`Expected a type annotation at offset ${this.pos}`);
|
||||
return { type, hasDefault: false };
|
||||
}
|
||||
readBalancedBraces() {
|
||||
this.skipTrivia();
|
||||
const { src } = this;
|
||||
@@ -215,6 +282,62 @@ class Lexer {
|
||||
}
|
||||
}
|
||||
|
||||
// ../../packages/compiler/src/types.ts
|
||||
function runtimeTypeOf(annotation) {
|
||||
if (!annotation)
|
||||
return "unknown";
|
||||
const type = annotation.trim().replace(/^readonly\s+/, "");
|
||||
if (/^(?:string|String)(?:\s*\|\s*(?:null|undefined))*$/.test(type))
|
||||
return "string";
|
||||
if (/^(?:number|Number)(?:\s*\|\s*(?:null|undefined))*$/.test(type))
|
||||
return "number";
|
||||
if (/^(?:boolean|Boolean)(?:\s*\|\s*(?:null|undefined))*$/.test(type))
|
||||
return "boolean";
|
||||
if (/^bigint(?:\s*\|\s*(?:null|undefined))*$/.test(type))
|
||||
return "bigint";
|
||||
if (/^(?:Array\s*<|ReadonlyArray\s*<|.+\[\])/.test(type) || /^\[/.test(type))
|
||||
return "array";
|
||||
if (/^(?:Record\s*<|object\b|\{)/.test(type))
|
||||
return "object";
|
||||
if (/=>|^(?:Function|\([^)]*\)\s*=>)/.test(type))
|
||||
return "function";
|
||||
return "unknown";
|
||||
}
|
||||
function inferredRuntimeType(expression) {
|
||||
const value = expression.trim();
|
||||
if (/^["'`]/.test(value))
|
||||
return "string";
|
||||
if (/^-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(value))
|
||||
return "number";
|
||||
if (/^(?:true|false)$/.test(value))
|
||||
return "boolean";
|
||||
if (/^-?\d+n$/.test(value))
|
||||
return "bigint";
|
||||
if (value.startsWith("["))
|
||||
return "array";
|
||||
if (value.startsWith("{") || /^new\s+(?:Map|Set|Date)\b/.test(value))
|
||||
return "object";
|
||||
if (/^(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/.test(value)) {
|
||||
return "function";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
function validateTypedInitializer(name, annotation, expression) {
|
||||
if (!annotation || expression.trim() === "undefined" || expression.trim() === "null")
|
||||
return null;
|
||||
const expected = runtimeTypeOf(annotation);
|
||||
const actual = inferredRuntimeType(expression);
|
||||
if (expected === "unknown" || actual === "unknown" || expected === actual)
|
||||
return null;
|
||||
return `${name} is declared as ${annotation}, but its initializer is ${actual}`;
|
||||
}
|
||||
function eraseFunctionTypes(source) {
|
||||
return source.replace(/(\b(?:async\s+)?function\s+[A-Za-z_$][\w$]*\s*\()([^)]*)(\)\s*)(?::\s*([^{}=>]+)\s*)?(\{)/g, (_whole, open, params, close, _returnType, brace) => {
|
||||
const plainParams = params.split(",").map((param) => param.replace(/([A-Za-z_$][\w$]*)(\?)?\s*:\s*([^=]+?)(?=\s*=|$)/, "$1").trim()).join(", ");
|
||||
return `${open}${plainParams}${close}${brace}`;
|
||||
});
|
||||
}
|
||||
|
||||
// ../../packages/compiler/src/parser.ts
|
||||
var VOID_ELEMENTS = new Set([
|
||||
"area",
|
||||
@@ -282,6 +405,7 @@ function parse(source) {
|
||||
expect("lbrace");
|
||||
let layout;
|
||||
const props = [];
|
||||
const types = [];
|
||||
const states = [];
|
||||
const seo = {};
|
||||
const view = [];
|
||||
@@ -318,8 +442,19 @@ function parse(source) {
|
||||
throw new ParseError(`Expected a prop name at offset ${t.pos}`);
|
||||
}
|
||||
const pName = expect("ident").value;
|
||||
expect("eq");
|
||||
props.push({ name: pName, default: lx.readToLineEnd() });
|
||||
let valueType;
|
||||
let hasDefault = false;
|
||||
if (lx.peek().type === "colon") {
|
||||
lx.next();
|
||||
const annotation = lx.readTypeAnnotation();
|
||||
valueType = annotation.type;
|
||||
hasDefault = annotation.hasDefault;
|
||||
} else {
|
||||
expect("eq");
|
||||
hasDefault = true;
|
||||
}
|
||||
const defaultValue = hasDefault ? lx.readToLineEnd() : "undefined";
|
||||
props.push({ name: pName, valueType, required: !hasDefault, default: defaultValue });
|
||||
}
|
||||
expect("rbrace");
|
||||
break;
|
||||
@@ -327,8 +462,23 @@ function parse(source) {
|
||||
case "state": {
|
||||
lx.next();
|
||||
const sName = expect("ident").value;
|
||||
expect("eq");
|
||||
states.push({ name: sName, expr: lx.readToLineEnd() });
|
||||
let valueType;
|
||||
if (lx.peek().type === "colon") {
|
||||
lx.next();
|
||||
const annotation = lx.readTypeAnnotation();
|
||||
valueType = annotation.type;
|
||||
if (!annotation.hasDefault) {
|
||||
throw new ParseError(`State '${sName}' requires an initializer`);
|
||||
}
|
||||
} else {
|
||||
expect("eq");
|
||||
}
|
||||
states.push({ name: sName, valueType, expr: lx.readToLineEnd() });
|
||||
break;
|
||||
}
|
||||
case "types": {
|
||||
lx.next();
|
||||
types.push(lx.readBalancedBraces());
|
||||
break;
|
||||
}
|
||||
case "view": {
|
||||
@@ -465,12 +615,23 @@ function parse(source) {
|
||||
throw new ParseError(`Cannot watch undeclared state '${watcher.state}'`);
|
||||
}
|
||||
}
|
||||
for (const prop of props) {
|
||||
const problem = validateTypedInitializer(`Prop '${prop.name}'`, prop.valueType, prop.default);
|
||||
if (problem)
|
||||
throw new ParseError(problem);
|
||||
}
|
||||
for (const state of states) {
|
||||
const problem = validateTypedInitializer(`State '${state.name}'`, state.valueType, state.expr);
|
||||
if (problem)
|
||||
throw new ParseError(problem);
|
||||
}
|
||||
return {
|
||||
type: "page",
|
||||
kind,
|
||||
name,
|
||||
layout,
|
||||
props,
|
||||
types,
|
||||
states,
|
||||
seo,
|
||||
view,
|
||||
@@ -1149,6 +1310,11 @@ function generate(ast) {
|
||||
|
||||
`);
|
||||
const apiBindings = apiBindingMap(ast, helpers);
|
||||
const typeSource = ast.types.map((body2) => body2.trim()).filter(Boolean).join(`
|
||||
|
||||
`);
|
||||
if (typeSource)
|
||||
out.push(typeSource);
|
||||
if (helpers) {
|
||||
out.push(`// --- .wrn functions ---
|
||||
${helpers}`);
|
||||
@@ -1177,6 +1343,7 @@ ${css}
|
||||
}
|
||||
let body = templateEscape(html);
|
||||
const dynamicStateScope = ast.states.map((state) => `${JSON.stringify(state.name)}: (() => { try { return (${state.expr}); } catch { return undefined; } })()`).join(", ");
|
||||
const stateType = ast.states.length > 0 ? `{ ${ast.states.map((state) => `${JSON.stringify(state.name)}: ${state.valueType ?? "unknown"}`).join("; ")} }` : "Record<string, never>";
|
||||
loops.forEach((code, idx) => {
|
||||
body = body.replace(`\x00WRNEACH${idx}\x00`, () => code);
|
||||
});
|
||||
@@ -1200,7 +1367,7 @@ ${css}
|
||||
` : "";
|
||||
out.push(`export default async function ${ast.name}(ctx: any) {
|
||||
${decls}
|
||||
const __state = { ${dynamicStateScope} };
|
||||
const __state: ${stateType} = { ${dynamicStateScope} };
|
||||
|
||||
const __scopeValue = Object.entries(__state)
|
||||
.map(([key, value]) => {
|
||||
@@ -1226,7 +1393,7 @@ ${css}
|
||||
}`);
|
||||
} else {
|
||||
out.push(`export default function ${ast.name}(ctx: any) {
|
||||
const __state = { ${dynamicStateScope} };
|
||||
const __state: ${stateType} = { ${dynamicStateScope} };
|
||||
|
||||
const __scopeValue = Object.entries(__state)
|
||||
.map(([key, value]) => {
|
||||
@@ -1346,9 +1513,9 @@ function escLit(s) {
|
||||
return s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
|
||||
}
|
||||
function componentBehavior(ast) {
|
||||
const functions = ast.functions.map((body) => body.trim()).filter(Boolean).join(`
|
||||
const functions = eraseFunctionTypes(ast.functions.map((body) => body.trim()).filter(Boolean).join(`
|
||||
|
||||
`);
|
||||
`));
|
||||
const lifecycle = {
|
||||
...ast.lifecycle.mount?.trim() ? { mount: ast.lifecycle.mount.trim() } : {},
|
||||
...ast.lifecycle.update?.trim() ? { update: ast.lifecycle.update.trim() } : {},
|
||||
@@ -1562,7 +1729,9 @@ function generateComponent(ast) {
|
||||
const effectiveProps = ast.kind === "layout" && !ast.props.some((prop) => prop.name === "content") ? [
|
||||
{
|
||||
name: "content",
|
||||
default: '""'
|
||||
default: '""',
|
||||
valueType: "string",
|
||||
required: false
|
||||
},
|
||||
...ast.props
|
||||
] : ast.props;
|
||||
@@ -1600,10 +1769,13 @@ ${styles.map(styleEscape).join(`
|
||||
const behaviorAttr = behaviorAttribute(behavior);
|
||||
const decls = [];
|
||||
for (const prop of effectiveProps) {
|
||||
decls.push(` const ${nameRefs.get(prop.name)} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}));`);
|
||||
if (prop.required) {
|
||||
decls.push(` if (__p[${JSON.stringify(prop.name)}] === undefined) throw new TypeError(${JSON.stringify(`${ast.name} requires prop '${prop.name}' (${prop.valueType ?? "unknown"})`)});`);
|
||||
}
|
||||
decls.push(` const ${nameRefs.get(prop.name)}: ${prop.valueType ?? "any"} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}), ${JSON.stringify(runtimeTypeOf(prop.valueType))});`);
|
||||
}
|
||||
for (const state of ast.states) {
|
||||
decls.push(` let ${nameRefs.get(state.name)} = (${resolveExpr(state.expr)});`);
|
||||
decls.push(` let ${nameRefs.get(state.name)}${state.valueType ? `: ${state.valueType}` : ""} = (${resolveExpr(state.expr)});`);
|
||||
}
|
||||
const returnExpr = needsScope ? "`" + styleTag + `<div data-scope="\${__scope}"${behaviorAttr}>` + viewCode + "</div>`" : "`" + styleTag + viewCode + "`";
|
||||
const scopeLine = needsScope && scopeKeys.length > 0 ? ` const __scope = __wrnexusScopeDecl({ ${scopeKeys.map((key) => `${JSON.stringify(key)}: ${nameRefs.get(key)}`).join(", ")} });
|
||||
@@ -1617,20 +1789,35 @@ ${styles.map(styleEscape).join(`
|
||||
if (behavior) {
|
||||
out.push(`export const __wrnexusBehavior = ${JSON.stringify(behavior, null, 2)};`);
|
||||
}
|
||||
out.push(`function __coerce(v: any, def: any): any {
|
||||
const typeSource = ast.types.map((body) => body.trim()).filter(Boolean).join(`
|
||||
|
||||
`);
|
||||
if (typeSource)
|
||||
out.push(typeSource);
|
||||
if (effectiveProps.length > 0) {
|
||||
out.push(`export interface ${ast.name}Props {
|
||||
${effectiveProps.map((prop) => ` ${JSON.stringify(prop.name)}${prop.required ? "" : "?"}: ${prop.valueType ?? "unknown"};`).join(`
|
||||
`)}
|
||||
}`);
|
||||
}
|
||||
out.push(`function __coerce(v: any, def: any, declared: string = "unknown"): any {
|
||||
if (v === undefined || v === null) {
|
||||
return def;
|
||||
}
|
||||
|
||||
if (typeof def === "number") {
|
||||
return Number(v);
|
||||
if (declared === "number" || typeof def === "number") {
|
||||
const parsed = Number(v);
|
||||
if (!Number.isFinite(parsed)) throw new TypeError("Expected a finite number prop");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
if (typeof def === "boolean") {
|
||||
return v === true || v === "" || v === "true";
|
||||
if (declared === "boolean" || typeof def === "boolean") {
|
||||
if (v === true || v === "" || v === "true" || v === 1 || v === "1") return true;
|
||||
if (v === false || v === "false" || v === 0 || v === "0") return false;
|
||||
throw new TypeError("Expected a boolean prop");
|
||||
}
|
||||
|
||||
if (Array.isArray(def)) {
|
||||
if (declared === "array" || Array.isArray(def)) {
|
||||
if (Array.isArray(v)) {
|
||||
return v;
|
||||
}
|
||||
@@ -1640,6 +1827,7 @@ ${styles.map(styleEscape).join(`
|
||||
const parsed = JSON.parse(v);
|
||||
return Array.isArray(parsed) ? parsed : def;
|
||||
} catch {
|
||||
if (declared === "array") throw new TypeError("Expected an array prop");
|
||||
return def;
|
||||
}
|
||||
}
|
||||
@@ -1647,7 +1835,7 @@ ${styles.map(styleEscape).join(`
|
||||
return def;
|
||||
}
|
||||
|
||||
if (def !== null && typeof def === "object") {
|
||||
if (declared === "object" || (def !== null && typeof def === "object")) {
|
||||
if (
|
||||
v !== null &&
|
||||
typeof v === "object" &&
|
||||
@@ -1668,6 +1856,7 @@ ${styles.map(styleEscape).join(`
|
||||
? parsed
|
||||
: def;
|
||||
} catch {
|
||||
if (declared === "object") throw new TypeError("Expected an object prop");
|
||||
return def;
|
||||
}
|
||||
}
|
||||
@@ -1675,7 +1864,11 @@ ${styles.map(styleEscape).join(`
|
||||
return def;
|
||||
}
|
||||
|
||||
return String(v);
|
||||
if (declared === "bigint") return BigInt(v);
|
||||
if (declared === "function" && typeof v !== "function") {
|
||||
throw new TypeError("Expected a function prop");
|
||||
}
|
||||
return declared === "unknown" && def === undefined ? v : String(v);
|
||||
}
|
||||
|
||||
function __wireHtml(v: any): string {
|
||||
@@ -1775,7 +1968,7 @@ function __wireRaw(v: any): string {
|
||||
}
|
||||
const serverFunctionSource = serverFunctions ? `${serverFunctions}
|
||||
` : "";
|
||||
out.push(`export function render(props: Record<string, any> = {}): string {
|
||||
out.push(`export function render(props: ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record<string, any>"} = {} as ${effectiveProps.length > 0 ? `${ast.name}Props` : "Record<string, any>"}): string {
|
||||
` + ` const __p = props || {};
|
||||
` + (decls.length > 0 ? decls.join(`
|
||||
`) + `
|
||||
@@ -1988,15 +2181,20 @@ function generateNative(ast) {
|
||||
const states = new Set(ast.states.map((state) => state.name));
|
||||
const hooks = ast.states.map((state) => {
|
||||
const cap = state.name[0].toUpperCase() + state.name.slice(1);
|
||||
return ` const [${state.name}, set${cap}] = useState(${state.expr});`;
|
||||
return ` const [${state.name}, set${cap}] = useState${state.valueType ? `<${state.valueType}>` : ""}(${state.expr});`;
|
||||
}).join(`
|
||||
`);
|
||||
const body = ast.view.map((node) => renderNode2(node, states)).join("");
|
||||
const typeSource = ast.types.map((block) => block.trim()).filter(Boolean).join(`
|
||||
|
||||
`);
|
||||
return `// generated from .wrn for Expo/React Native
|
||||
import React, { useState } from "react";
|
||||
import { ActivityIndicator, FlatList, Image, Pressable, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
|
||||
${typeSource}
|
||||
|
||||
export default function ${ast.name}() {
|
||||
const router = useRouter();
|
||||
${hooks}
|
||||
|
||||
@@ -58,11 +58,23 @@ const BLOCK_COMPLETIONS = [
|
||||
"}",
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
label: "types",
|
||||
detail: "TypeScript type declarations block",
|
||||
documentation: "Declare interfaces and type aliases used by props, state, and functions.",
|
||||
snippet: [
|
||||
"types {",
|
||||
" interface ${1:Item} {",
|
||||
" ${2:id}: ${3:string}",
|
||||
" }",
|
||||
"}",
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
label: "props",
|
||||
detail: "Component or layout props block",
|
||||
documentation: "Declare values accepted by a component or layout.",
|
||||
snippet: ["props {", ' ${1:title} = "${2:Title}"', "}"].join("\n"),
|
||||
snippet: ["props {", ' ${1:title}: ${2:string} = "${3:Title}"', "}"].join("\n"),
|
||||
},
|
||||
{
|
||||
label: "seo",
|
||||
@@ -85,22 +97,28 @@ const BLOCK_COMPLETIONS = [
|
||||
label: "state",
|
||||
detail: "Reactive state declaration",
|
||||
documentation: "Declare reactive state owned by the current component, layout, or page.",
|
||||
snippet: 'state ${1:name} = ${2:"value"}',
|
||||
snippet: 'state ${1:name}: ${2:string} = ${3:"value"}',
|
||||
},
|
||||
{
|
||||
label: "functions",
|
||||
detail: "Browser component functions block",
|
||||
documentation:
|
||||
"Declare functions that can be called from events, lifecycle hooks, and watchers.",
|
||||
snippet: ["functions {", " function ${1:handler}(${2}) {", " $0", " }", "}"].join(
|
||||
"\n",
|
||||
),
|
||||
snippet: [
|
||||
"functions {",
|
||||
" function ${1:handler}(${2:value}: ${3:unknown}): ${4:void} {",
|
||||
" $0",
|
||||
" }",
|
||||
"}",
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
label: "function",
|
||||
detail: "WRN component function",
|
||||
documentation: "Declare a browser-side function inside a functions block.",
|
||||
snippet: ["function ${1:name}(${2}) {", " $0", "}"].join("\n"),
|
||||
snippet: ["function ${1:name}(${2:value}: ${3:unknown}): ${4:void} {", " $0", "}"].join(
|
||||
"\n",
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "lifecycle",
|
||||
@@ -278,7 +296,7 @@ function extractRouteParams(document) {
|
||||
function extractStates(document) {
|
||||
const source = document.getText();
|
||||
|
||||
const matches = [...source.matchAll(/^\s*state\s+([A-Za-z_$][\w$]*)\s*=/gm)];
|
||||
const matches = [...source.matchAll(/^\s*state\s+([A-Za-z_$][\w$]*)(?:\s*:\s*[^=\r\n]+)?\s*=/gm)];
|
||||
|
||||
return [...new Set(matches.map((match) => match[1]))];
|
||||
}
|
||||
@@ -293,7 +311,9 @@ function extractProps(document) {
|
||||
while ((blockMatch = propsBlockPattern.exec(source)) !== null) {
|
||||
const body = blockMatch[1];
|
||||
|
||||
for (const match of body.matchAll(/^\s*([A-Za-z_$][\w$]*)\s*=/gm)) {
|
||||
for (const match of body.matchAll(
|
||||
/^\s*([A-Za-z_$][\w$]*)(?:\s*:\s*[^=\r\n]+)?(?:\s*=|\s*$)/gm,
|
||||
)) {
|
||||
props.add(match[1]);
|
||||
}
|
||||
}
|
||||
@@ -323,7 +343,12 @@ function extractFunctions(document) {
|
||||
.split(",")
|
||||
.map((parameter) => parameter.trim())
|
||||
.filter(Boolean)
|
||||
.map((parameter) => parameter.replace(/=.*$/, "").trim());
|
||||
.map((parameter) =>
|
||||
parameter
|
||||
.replace(/=.*$/, "")
|
||||
.replace(/\??\s*:\s*[\s\S]+$/, "")
|
||||
.trim(),
|
||||
);
|
||||
|
||||
functions.push({
|
||||
name,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,6 +15,7 @@ const VALID_MEMBERS = {
|
||||
page: new Set([
|
||||
"layout",
|
||||
"state",
|
||||
"types",
|
||||
"view",
|
||||
"seo",
|
||||
"style",
|
||||
@@ -25,9 +26,18 @@ const VALID_MEMBERS = {
|
||||
"realtime",
|
||||
]),
|
||||
|
||||
component: new Set(["props", "state", "view", "style", "functions", "lifecycle", "watch"]),
|
||||
component: new Set([
|
||||
"types",
|
||||
"props",
|
||||
"state",
|
||||
"view",
|
||||
"style",
|
||||
"functions",
|
||||
"lifecycle",
|
||||
"watch",
|
||||
]),
|
||||
|
||||
layout: new Set(["props", "state", "view", "style", "functions", "lifecycle", "watch"]),
|
||||
layout: new Set(["types", "props", "state", "view", "style", "functions", "lifecycle", "watch"]),
|
||||
};
|
||||
|
||||
function createDiagnostic(
|
||||
|
||||
@@ -49,6 +49,9 @@
|
||||
{
|
||||
"include": "#seo-block"
|
||||
},
|
||||
{
|
||||
"include": "#types-block"
|
||||
},
|
||||
{
|
||||
"include": "#props-block"
|
||||
},
|
||||
@@ -93,7 +96,7 @@
|
||||
}
|
||||
},
|
||||
"state-decl": {
|
||||
"begin": "\\b(state)\\b\\s+([A-Za-z_$][A-Za-z0-9_$]*)\\s*(=)",
|
||||
"begin": "\\b(state)\\b\\s+([A-Za-z_$][A-Za-z0-9_$]*)(?:\\s*(:)\\s*([^=\\r\\n]+?))?\\s*(=)",
|
||||
"beginCaptures": {
|
||||
"1": {
|
||||
"name": "keyword.control.wrn"
|
||||
@@ -102,6 +105,12 @@
|
||||
"name": "variable.other.wrn"
|
||||
},
|
||||
"3": {
|
||||
"name": "punctuation.separator.type.wrn"
|
||||
},
|
||||
"4": {
|
||||
"name": "storage.type.wrn"
|
||||
},
|
||||
"5": {
|
||||
"name": "keyword.operator.assignment.wrn"
|
||||
}
|
||||
},
|
||||
@@ -113,6 +122,18 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"types-block": {
|
||||
"begin": "\\b(types)\\b\\s*(\\{)",
|
||||
"beginCaptures": {
|
||||
"1": { "name": "keyword.control.wrn" },
|
||||
"2": { "name": "punctuation.definition.block.begin.wrn" }
|
||||
},
|
||||
"end": "\\}",
|
||||
"endCaptures": {
|
||||
"0": { "name": "punctuation.definition.block.end.wrn" }
|
||||
},
|
||||
"patterns": [{ "include": "source.ts" }]
|
||||
},
|
||||
"props-block": {
|
||||
"begin": "\\b(props)\\b\\s*(\\{)",
|
||||
"beginCaptures": {
|
||||
@@ -134,12 +155,18 @@
|
||||
"include": "#comments"
|
||||
},
|
||||
{
|
||||
"match": "\\b([A-Za-z_$][A-Za-z0-9_$]*)\\s*(=)",
|
||||
"match": "\\b([A-Za-z_$][A-Za-z0-9_$]*)(?:\\s*(:)\\s*([^=\\r\\n]+?))?\\s*(=|$)",
|
||||
"captures": {
|
||||
"1": {
|
||||
"name": "variable.parameter.wrn"
|
||||
},
|
||||
"2": {
|
||||
"name": "punctuation.separator.type.wrn"
|
||||
},
|
||||
"3": {
|
||||
"name": "storage.type.wrn"
|
||||
},
|
||||
"4": {
|
||||
"name": "keyword.operator.assignment.wrn"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,3 +80,31 @@ test("recognizes literal attribute value types", () => {
|
||||
assert.equal(attributeValueType("{[1, 2]}"), "array");
|
||||
assert.equal(attributeValueType("plain"), "string");
|
||||
});
|
||||
|
||||
test("extracts explicit prop types and resolves typed prop/state expressions", () => {
|
||||
const input = `component DatePicker {
|
||||
props {
|
||||
required: boolean = false
|
||||
value: string
|
||||
}
|
||||
state count: number = 0
|
||||
view { <DateInput required="{required}" value="{value}" count="{count}" /> }
|
||||
}`;
|
||||
const target = parseComponentMetadata(`component DateInput {
|
||||
props {
|
||||
required: boolean = false
|
||||
value: string
|
||||
count: number = 0
|
||||
}
|
||||
view { <input/> }
|
||||
}`);
|
||||
const own = parseComponentMetadata(input);
|
||||
assert.deepEqual(
|
||||
own.props.map(({ name, type, required }) => ({ name, type, required })),
|
||||
[
|
||||
{ name: "required", type: "boolean", required: false },
|
||||
{ name: "value", type: "string", required: true },
|
||||
],
|
||||
);
|
||||
assert.deepEqual(validateComponentTags(input, new Map([["DateInput", target]])), []);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user