From 3d10bfa0052322051027a33b79b03032d2957e42 Mon Sep 17 00:00:00 2001 From: Tki_sor <1151520250@qq.com> Date: Sun, 26 Jul 2026 01:21:11 +0800 Subject: [PATCH] feat: update Graal to 25.1.3.6, improve JSX/TS compiler and tests --- README.md | 4 +- common/build.gradle | 8 + .../compiler/NekoCompilationPipeline.java | 10 +- .../nekojs/core/compiler/NekoJsxCompiler.java | 374 ++++++++++++++- .../nekojs/core/compiler/NekoJsxLexer.java | 8 +- .../core/compiler/NekoSourceMapBuilder.java | 14 + .../core/compiler/NekoTypeScriptCompiler.java | 448 +++++++++++++++++- .../core/config/SandboxConfigLoader.java | 2 +- .../tkisor/nekojs/core/fs/JSConfigModel.java | 17 +- .../core/module/NekoModulePipeline.java | 6 +- .../core/module/NekoModuleResolver.java | 54 ++- .../nekojs/api/JavaMemberIndexTest.java | 154 ++++-- .../nekojs/api/data/BindingPriorityTest.java | 36 ++ .../compiler/CompilerExecutionAssertions.java | 71 +++ ...NekoCompilationPipelineJsxRuntimeTest.java | 39 ++ .../core/compiler/NekoJsxAttributesTest.java | 64 +++ .../core/compiler/NekoJsxCompilerTest.java | 295 ++++++++++++ .../core/compiler/NekoJsxFragmentTest.java | 52 ++ .../compiler/NekoTypeScriptCompilerTest.java | 271 +++++++++++ .../core/compiler/NodeModuleTypeDocsTest.java | 309 ++++++++++++ .../nekojs/core/fs/JSConfigModelTest.java | 41 ++ .../core/module/NekoModuleResolverTest.java | 92 ++++ .../core/plugin/BindingSideSelectionTest.java | 45 ++ .../nekojs/core/plugin/DeepFreezeTest.java | 59 +++ .../platform/PlatformCapabilityTest.java | 26 + .../nekojs/probe/ClassDeclGeneratorTest.java | 49 ++ .../nekojs/probe/ProbeRegistryTest.java | 88 ++++ .../nekojs/testfixture/TestPlatformInit.java | 82 ++++ 28 files changed, 2626 insertions(+), 92 deletions(-) create mode 100644 common/src/test/java/com/tkisor/nekojs/api/data/BindingPriorityTest.java create mode 100644 common/src/test/java/com/tkisor/nekojs/core/compiler/CompilerExecutionAssertions.java create mode 100644 common/src/test/java/com/tkisor/nekojs/core/compiler/NekoCompilationPipelineJsxRuntimeTest.java create mode 100644 common/src/test/java/com/tkisor/nekojs/core/compiler/NekoJsxAttributesTest.java create mode 100644 common/src/test/java/com/tkisor/nekojs/core/compiler/NekoJsxCompilerTest.java create mode 100644 common/src/test/java/com/tkisor/nekojs/core/compiler/NekoJsxFragmentTest.java create mode 100644 common/src/test/java/com/tkisor/nekojs/core/compiler/NodeModuleTypeDocsTest.java create mode 100644 common/src/test/java/com/tkisor/nekojs/core/fs/JSConfigModelTest.java create mode 100644 common/src/test/java/com/tkisor/nekojs/core/module/NekoModuleResolverTest.java create mode 100644 common/src/test/java/com/tkisor/nekojs/core/plugin/BindingSideSelectionTest.java create mode 100644 common/src/test/java/com/tkisor/nekojs/core/plugin/DeepFreezeTest.java create mode 100644 common/src/test/java/com/tkisor/nekojs/platform/PlatformCapabilityTest.java create mode 100644 common/src/test/java/com/tkisor/nekojs/probe/ClassDeclGeneratorTest.java create mode 100644 common/src/test/java/com/tkisor/nekojs/probe/ProbeRegistryTest.java create mode 100644 common/src/test/java/com/tkisor/nekojs/testfixture/TestPlatformInit.java diff --git a/README.md b/README.md index af19183d..57a8fab3 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ NekoJS 生成的类型声明(`.neko_probe/`)已经把 `ServerEvents`、`Bloc * 拼写错误(如把 `event.recipes` 写成 `event.rec`)会立即被标红,无需 `import` 任何类型 —— 全局事件对象的签名会自动推断 `event` 的类型。 * 运行时(游戏内)同样会拦截这类错误:事件回调里访问不存在的成员、或使用未定义的变量,会被记录到错误面板,用 `/nekojs view_all_errors` 查看。 -> 注意:NekoJS 内置的是 erasable TypeScript 前端(类型标注会在运行前擦除),暂不支持 `enum` / `namespace` / `module` 等 TS 语法;这类语法会在加载时报错,请在脚本中避免。 +> 注意:NekoJS 内置的 TypeScript 前端支持「可擦除」语法(类型标注、`type`/`interface`、泛型、`as`/`satisfies`、`import type`/`export type`、`declare`、参数属性 `constructor(public x)`、`enum`/`const enum`、`namespace`/`module`、类成员修饰符、`?.`/`!`、函数重载签名等)——这些会在运行前擦除或降级为运行时 IIFE/赋值。**不支持** 装饰器(`@Decorator`)—— NekoJS 是脚本引擎非 TS 框架,遇到装饰器会清晰报错,请改用普通函数包装。 --- @@ -169,7 +169,7 @@ NekoJS 的脚本运行在受限 GraalJS 环境中,但它不是“不可信代 ### 语言前端 -NekoJS 核心主打轻量与稳定,内置 `.ts` 的 erasable TypeScript 支持:类型标注、`type` / `interface`、`import type` / `export type` 等会在 Java 前端中擦除,之后继续走 NekoJS 自有 ESM/CJS pipeline。NekoJS 也内置轻量 `.jsx/.tsx` classic runtime lowering,会把 JSX 元素降到 `globalThis.__nekoJsxFactory(...)` / `globalThis.__nekoJsxFragment(...)`。 +NekoJS 核心主打轻量与稳定,内置 `.ts` 的 TypeScript 支持:类型标注、`type` / `interface`、`import type` / `export type`、泛型(含泛型箭头 `(x: T) => T`)、`as` / `satisfies`、内联 `import { x, type T }`、参数属性、`enum` / `namespace`、类成员修饰符等会在 Java 前端中擦除或降级,之后继续走 NekoJS 自有 ESM/CJS pipeline。NekoJS 也内置 `.jsx/.tsx` lowering:默认 classic runtime(`globalThis.__nekoJsxFactory(...)` / `globalThis.__nekoJsxFragment(...)`),支持 HTML 实体解码、命名空间标签(``)、泛型组件(`/>`);在 `nekojs/config/engine.toml` 里设 `jsxAutomaticRuntime = true` 可切换到标准 automatic runtime:从 `nekojs/jsx-runtime` 导入 `jsx`、`jsxs` 和 `Fragment`,子节点放在 `props.children`。在 `nekojs/` 工作区内,请将 runtime 模块放在裸模块路径 `node_modules/nekojs/jsx-runtime.js`。 后续方向是继续增强 NekoJS 本体语言前端,而不是依赖外部 NekoSWC 模组来承担高级 TS/TSX/JSX 转换。脚本语言插件 registry 仍保留给第三方语言扩展使用,但 NekoJS 自身的 TypeScript、JSX、sourcemap chain 和 diagnostics 会优先在本体实现。 diff --git a/common/build.gradle b/common/build.gradle index 601f07d1..725f8a11 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -35,6 +35,7 @@ java.toolchain.languageVersion = JavaLanguageVersion.of(21) dependencies { testImplementation platform('org.junit:junit-bom:6.0.0') testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation 'com.google.code.gson:gson:2.11.0' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' // 25.1.3.6; exported so every platform receives the same remapper SPI. api("curse.maven:graal-1504336:8456810") @@ -50,6 +51,13 @@ dependencies { compileOnly 'org.projectlombok:lombok:1.18.44' annotationProcessor 'org.projectlombok:lombok:1.18.44' + // 测试运行时 stub:compiler/probe 链上的类引用 gson/log4j 等,游戏内由 MC/NeoForge 提供, + // 但 common 独立测试时需要在 testRuntimeOnly 提供这些实现,否则 NoClassDefFoundError。 + testRuntimeOnly 'org.slf4j:slf4j-api:2.0.13' + testRuntimeOnly 'org.apache.logging.log4j:log4j-api:2.20.0' + testRuntimeOnly 'org.apache.logging.log4j:log4j-core:2.20.0' + testRuntimeOnly 'com.ibm.icu:icu4j:77.1' + } test { diff --git a/common/src/main/java/com/tkisor/nekojs/core/compiler/NekoCompilationPipeline.java b/common/src/main/java/com/tkisor/nekojs/core/compiler/NekoCompilationPipeline.java index fa6d2f08..53236656 100644 --- a/common/src/main/java/com/tkisor/nekojs/core/compiler/NekoCompilationPipeline.java +++ b/common/src/main/java/com/tkisor/nekojs/core/compiler/NekoCompilationPipeline.java @@ -7,8 +7,16 @@ public final class NekoCompilationPipeline { public NekoCompileOutput compile(Path file, String source, String extension, NekoLanguagePlugin language) throws Exception { + return compile(file, source, extension, language, false); + } + + public NekoCompileOutput compile(Path file, String source, String extension, NekoLanguagePlugin language, + boolean jsxAutomaticRuntime) throws Exception { NekoSourceFile sourceFile = new NekoSourceFile(file, source, extension); - NekoTokenStream tokens = language.lexer().tokenize(sourceFile); + NekoLexer lexer = language.lexer(); + NekoTokenStream tokens = lexer instanceof NekoJsxLexer jsxLexer + ? jsxLexer.tokenize(sourceFile, jsxAutomaticRuntime) + : lexer.tokenize(sourceFile); NekoSourceAst ast = language.parser().parse(tokens); NekoIRProgram program = language.lowering().lower(ast); NekoEsmModuleAst esmAst = ast instanceof NekoEsmSourceAst esm ? esm.esmAst() : null; diff --git a/common/src/main/java/com/tkisor/nekojs/core/compiler/NekoJsxCompiler.java b/common/src/main/java/com/tkisor/nekojs/core/compiler/NekoJsxCompiler.java index c353796f..1a000283 100644 --- a/common/src/main/java/com/tkisor/nekojs/core/compiler/NekoJsxCompiler.java +++ b/common/src/main/java/com/tkisor/nekojs/core/compiler/NekoJsxCompiler.java @@ -9,12 +9,26 @@ public final class NekoJsxCompiler { private NekoJsxCompiler() {} public static ScriptCompileResult compileJsx(Path file, String source) { - JsxTransformResult result = new Transpiler(file, source == null ? "" : source).transpileDetailed(); + return compileJsx(file, source, false); + } + + /** + * 编译 .jsx。 + * @param automatic true 用标准自动 runtime({@code jsx}/{@code jsxs}/{@code Fragment},children 在 props 中), + * false 用 classic runtime({@code globalThis.__nekoJsxFactory}/{@code __nekoJsxFragment})。 + */ + public static ScriptCompileResult compileJsx(Path file, String source, boolean automatic) { + JsxTransformResult result = new Transpiler(file, source == null ? "" : source, automatic).transpileDetailed(); return new ScriptCompileResult(result.code(), result.sourceMap()); } public static ScriptCompileResult compileTsx(Path file, String source) { - JsxTransformResult lowered = new Transpiler(file, source == null ? "" : source).transpileDetailed(); + return compileTsx(file, source, false); + } + + /** 同 {@link #compileTsx(Path, String)} 但可选自动 runtime。 */ + public static ScriptCompileResult compileTsx(Path file, String source, boolean automatic) { + JsxTransformResult lowered = new Transpiler(file, source == null ? "" : source, automatic).transpileDetailed(); NekoTypeScriptCompiler.TypeScriptTransformResult erased = NekoTypeScriptCompiler.erasePreservingSourceMap(file, lowered.code(), lowered.sourceMap()); return new ScriptCompileResult(erased.code(), erased.sourceMap()); } @@ -23,11 +37,17 @@ private static final class Transpiler { private final Path file; private final String source; private final int length; + private final boolean automatic; + private boolean suppressRuntimeImport; + private boolean usedJsx; + private boolean usedFragment; + private boolean usedJsxs; - private Transpiler(Path file, String source) { + private Transpiler(Path file, String source, boolean automatic) { this.file = file; this.source = source == null ? "" : source; this.length = this.source.length(); + this.automatic = automatic; } private String transpile() { @@ -66,7 +86,43 @@ private JsxTransformResult transpileDetailed() { index++; } output.appendOriginalRange(last, length); - return new JsxTransformResult(output.code(), output.sourceMap()); + String code = output.code(); + String sourceMap = output.sourceMap(); + if (automatic && !suppressRuntimeImport) { + String runtimeImport = jsxRuntimeImport(); + if (!runtimeImport.isEmpty()) { + code = runtimeImport + code; + sourceMap = NekoSourceMapBuilder.prependUnmappedGeneratedLines(sourceMap, 1); + } + } + return new JsxTransformResult(code, sourceMap); + } + + /** + * 自动 runtime:在文件头注入标准 import。 + * 仅按实际使用注入 {@code jsx}/{@code jsxs}/{@code Fragment}。 + */ + private String jsxRuntimeImport() { + if (!usedJsx && !usedJsxs && !usedFragment) { + return ""; + } + StringBuilder imp = new StringBuilder("import { "); + boolean needsComma = false; + if (usedJsx) { + imp.append("jsx"); + needsComma = true; + } + if (usedJsxs) { + if (needsComma) imp.append(", "); + imp.append("jsxs"); + needsComma = true; + } + if (usedFragment) { + if (needsComma) imp.append(", "); + imp.append("Fragment"); + } + imp.append(" } from 'nekojs/jsx-runtime';\n"); + return imp.toString(); } private ParseResult parseJsx(int start) { @@ -82,6 +138,19 @@ private ParseResult parseJsx(int start) { String tagName = source.substring(nameStart, nameEnd); int index = nameEnd; + // 泛型组件 />:JSX 层把 作为 tag 表达式的一部分透传, + // TSX 模式下由后续 TS 擦除阶段处理;.jsx 模式下不应出现(用户责任)。 + int genericArgsEnd = skipGenericArgsIfPresent(index); + String tagExpr; + if (genericArgsEnd > index) { + // 泛型参数仅用于 TS 类型检查,不能成为运行时组件表达式。 + // compileTsx 的擦除器会处理完整的 TypeScript 源;这里直接保留标签值, + // 同时消费参数段,避免对象类型中的 {…} 被误作 JSX 属性。 + tagExpr = tagName; + index = genericArgsEnd; + } else { + tagExpr = tagName; + } List props = new ArrayList<>(); boolean selfClosing = false; @@ -104,7 +173,7 @@ private ParseResult parseJsx(int start) { index = attribute.nextIndex(); } - GeneratedPart typeExpression = new GeneratedPart(jsxTagExpression(tagName), List.of(new NekoSourceMapBuilder.MappingPoint(0, nameStart))); + GeneratedPart typeExpression = new GeneratedPart(jsxTagExpression(tagExpr), List.of(new NekoSourceMapBuilder.MappingPoint(0, nameStart))); GeneratedPart propsExpression = props.isEmpty() ? GeneratedPart.unmapped("null") : objectPart(props); if (selfClosing) { return factoryCall(start, typeExpression, propsExpression, List.of(), index); @@ -250,7 +319,18 @@ private String transform(String innerSource) { if (innerSource == null || innerSource.isBlank()) { return innerSource == null ? "" : innerSource; } - return new Transpiler(file, innerSource).transpile(); + // 子表达式递归 lowering:复用本 Transpiler 的 automatic 标志(同一文件 runtime 模式一致); + // 内部不再 prepend import(由最外层统一注入)。 + Transpiler inner = new Transpiler(file, innerSource, automatic); + inner.suppressRuntimeImport = true; + inner.usedJsx = this.usedJsx; + inner.usedFragment = this.usedFragment; + inner.usedJsxs = this.usedJsxs; + String lowered = inner.transpile(); + if (inner.usedJsx) this.usedJsx = true; + if (inner.usedFragment) this.usedFragment = true; + if (inner.usedJsxs) this.usedJsxs = true; + return lowered; } private List expressionMappings(String transformed, int generatedStart, int originalStart) { @@ -270,10 +350,70 @@ private String normalizeText(String raw) { } String normalized = raw.replace('\r', ' '); normalized = normalized.replaceAll("\\s+", " ").trim(); - return normalized.isEmpty() ? null : normalized; + if (normalized.isEmpty()) return null; + return decodeHtmlEntities(normalized); + } + + /** + * 解码 JSX 文本里的 HTML 实体(与 React/JSX 规范一致)。 + * 支持命名实体(amp/lt/gt/quot/apos/nbsp)与数字实体(&#NN; / &#xHH;)。 + * 字符串属性值里的实体是字面量(由 parseAttribute 原样保留),本方法只作用于元素文本。 + */ + private String decodeHtmlEntities(String text) { + if (text.indexOf('&') < 0) return text; + StringBuilder sb = new StringBuilder(text.length()); + int i = 0, n = text.length(); + while (i < n) { + char c = text.charAt(i); + if (c != '&') { sb.append(c); i++; continue; } + int semi = text.indexOf(';', i + 1); + if (semi < 0 || semi - i > 10) { sb.append(c); i++; continue; } // 不是实体,原样 + String body = text.substring(i + 1, semi); + String decoded = decodeEntityBody(body); + if (decoded != null) { + sb.append(decoded); + i = semi + 1; + } else { + sb.append(c); i++; // 无法识别,原样保留 & + } + } + return sb.toString(); + } + + private String decodeEntityBody(String body) { + switch (body) { + case "amp": return "&"; + case "lt": return "<"; + case "gt": return ">"; + case "quot": return "\""; + case "apos": return "'"; + case "nbsp": return "\u00a0"; + default: + // 数字实体 ' / ' + if (body.startsWith("#")) { + try { + int code; + if (body.length() > 1 && (body.charAt(1) == 'x' || body.charAt(1) == 'X')) { + code = Integer.parseInt(body.substring(2), 16); + } else { + code = Integer.parseInt(body.substring(1), 10); + } + if (code < 0 || code > 0x10FFFF) return null; + return new String(Character.toChars(code)); + } catch (NumberFormatException e) { + return null; + } + } + return null; + } } private String jsxTagExpression(String tagName) { + // 命名空间标签不是合法的 JavaScript 表达式,须作为完整字符串传给 factory。 + if (tagName.indexOf(':') >= 0) { + return stringLiteral(tagName); + } + // 成员表达式 / 索引 / 调用作为组件表达式透传。 if (tagName.indexOf('.') >= 0 || tagName.indexOf('[') >= 0 || tagName.indexOf('(') >= 0) { return tagName; } @@ -306,7 +446,8 @@ private int readJsxNameEnd(int start) { int i = start; while (i < length) { char c = source.charAt(i); - if (Character.isWhitespace(c) || c == '/' || c == '>' || c == '{' || c == '=') { + // < 用于泛型组件 /> —— 在 < 处停,让上层识别并跳过泛型实参 + if (Character.isWhitespace(c) || c == '/' || c == '>' || c == '{' || c == '=' || c == '<') { break; } i++; @@ -314,6 +455,27 @@ private int readJsxNameEnd(int start) { return i; } + /** + * 若 index 处起是泛型组件的 {@code } 实参段(紧跟标签名后),返回其结束位置({@code >} 之后); + * 否则返回 index。跳过字符串/嵌套尖括号。注意:TSX 泛型实参里不会出现 {@code /}(那是自闭合), + * 遇到 {@code />} 当作无泛型(实际是 {@code <} 比较表达式)。 + */ + private int skipGenericArgsIfPresent(int index) { + if (index >= length || source.charAt(index) != '<') return index; + int depth = 0; + int i = index; + while (i < length) { + char c = source.charAt(i); + if (c == '\'' || c == '"') { i = skipString(i, c); continue; } + if (c == '`') { i = skipTemplate(i); continue; } + if (c == '<') depth++; + else if (c == '>') { depth--; if (depth == 0) return i + 1; } + else if (depth == 1 && c == '/') return index; // (x: T) => x 与 JSX 开标签同形。先识别完整泛型箭头, + // 交给后续 TS 擦除;否则会被 parseJsx 当成未闭合元素。 + if (looksLikeTsxGenericArrow(index)) { + return false; + } int previous = previousNonWhitespace(index - 1); if (previous < 0) { @@ -366,6 +533,53 @@ private boolean looksLikeJsxStart(int index) { return false; } + /** 判断 (...) => 或 (...) => 的 TSX 泛型箭头歧义形式。 */ + private boolean looksLikeTsxGenericArrow(int start) { + int typeParametersEnd = skipGenericArgsIfPresent(start); + if (typeParametersEnd == start) { + return false; + } + int parameters = skipWhitespace(typeParametersEnd); + if (peek(parameters) != '(') { + return false; + } + int closeParameters = findMatchingParen(parameters); + if (closeParameters < 0) { + return false; + } + int afterParameters = skipWhitespace(closeParameters + 1); + if (peek(afterParameters) == ':') { + afterParameters = skipTsType(afterParameters + 1); + } + afterParameters = skipWhitespace(afterParameters); + return peek(afterParameters) == '=' && peek(afterParameters + 1) == '>'; + } + + private int findMatchingParen(int openParen) { + int depth = 0; + for (int i = openParen; i < length; i++) { + char c = source.charAt(i); + if (c == '\'' || c == '"') { i = skipString(i, c) - 1; continue; } + if (c == '`') { i = skipTemplate(i) - 1; continue; } + if (c == '(') depth++; + else if (c == ')' && --depth == 0) return i; + } + return -1; + } + + private int skipTsType(int index) { + int angleDepth = 0; + int i = index; + while (i < length) { + char c = source.charAt(i); + if (c == '<') angleDepth++; + else if (c == '>') angleDepth--; + else if (angleDepth == 0 && (c == '=' || c == ',' || c == ')' || c == ';' || c == '\n' || c == '\r')) break; + i++; + } + return i; + } + private boolean isIgnorableExpression(String inner) { int i = 0; while (i < inner.length()) { @@ -396,31 +610,64 @@ private int findMatchingBrace(int openBrace) { while (i < length) { char c = source.charAt(i); if (c == '\'' || c == '"') { - i = skipString(i, c); - continue; - } - if (c == '`') { - i = skipTemplate(i); - continue; + i = skipString(i, c); continue; } + if (c == '`') { i = skipTemplate(i); continue; } if (c == '/') { - int skipped = skipSlash(i); - if (skipped != i) { - i = skipped; + if (i + 1 < length && source.charAt(i + 1) == '/') { i = skipLineComment(i + 2); continue; } + if (i + 1 < length && source.charAt(i + 1) == '*') { i = skipBlockComment(i + 2); continue; } + // 正则体可含 }(含字符类)。仅在 JS 正则可起始的位置跳过, + // 避免把 JSX closing tag 的 = 0 && source.charAt(previous) != '<' + && (looksLikeRegexStart(i) || followsControlCondition(i, previous))) { + i = skipRegex(i + 1); continue; } } - if (c == '{') { + if (c == '{') { depth++; } + else if (c == '}') { depth--; if (depth == 0) return i; } + i++; + } + throw jsxError("Unterminated JSX expression", openBrace); + } + + /** + * A regex literal may begin immediately after a control statement's closing parenthesis, + * e.g. {@code if (condition) /pattern/.test(value)}. A bare {@code )} is otherwise a + * value boundary, so it must not classify {@code 10 / 2} as a regex. + */ + private boolean followsControlCondition(int slash, int closingParen) { + if (source.charAt(closingParen) != ')') { + return false; + } + int depth = 1; + for (int i = closingParen - 1; i >= 0; i--) { + char c = source.charAt(i); + if (c == ')') { depth++; - } else if (c == '}') { - depth--; - if (depth == 0) { - return i; + } else if (c == '(' && --depth == 0) { + int wordEnd = previousNonWhitespace(i - 1); + if (wordEnd >= 1 && source.charAt(wordEnd) == '/' && source.charAt(wordEnd - 1) == '*') { + int commentStart = source.lastIndexOf("/*", wordEnd - 2); + if (commentStart < 0) { + return false; + } + wordEnd = previousNonWhitespace(commentStart - 1); + } + if (wordEnd < 0 || !isIdentifierPart(source.charAt(wordEnd))) { + return false; } + int wordStart = wordEnd; + while (wordStart >= 0 && isIdentifierPart(source.charAt(wordStart))) { + wordStart--; + } + String keyword = source.substring(wordStart + 1, wordEnd + 1); + return "if".equals(keyword) || "while".equals(keyword) + || "for".equals(keyword) || "with".equals(keyword); } - i++; } - throw jsxError("Unterminated JSX expression", openBrace); + return false; } private int skipWhitespace(int index) { @@ -497,6 +744,24 @@ private boolean isIdentifierPart(char c) { } private ParseResult factoryCall(int originalStart, GeneratedPart typeExpression, GeneratedPart propsExpression, List children, int nextIndex) { + if (automatic) { + // 标准 automatic runtime: + // - 0/1 child → jsx(type, props) + // - 2+ children → jsxs(type, props) + // - children 始终放在 props.children(单值或数组) + boolean multi = children.size() > 1; + if (multi) { + usedJsxs = true; + } else { + usedJsx = true; + } + GeneratedAssembler call = new GeneratedAssembler(multi ? "jsxs(" : "jsx(", originalStart); + call.append(typeExpression); + call.append(", "); + call.append(automaticProps(propsExpression, children)); + call.append(")"); + return new ParseResult(call.text(), call.mappings(), nextIndex); + } GeneratedAssembler call = new GeneratedAssembler("globalThis.__nekoJsxFactory(", originalStart); call.append(typeExpression); call.append(", "); @@ -510,6 +775,22 @@ private ParseResult factoryCall(int originalStart, GeneratedPart typeExpression, } private ParseResult fragmentCall(int originalStart, List children, int nextIndex) { + if (automatic) { + // Fragment 是类型值,不是可调用函数:jsx(Fragment, props) / jsxs(Fragment, props) + usedFragment = true; + boolean multi = children.size() > 1; + if (multi) { + usedJsxs = true; + } else { + usedJsx = true; + } + GeneratedAssembler call = new GeneratedAssembler(multi ? "jsxs(" : "jsx(", originalStart); + call.append("Fragment"); + call.append(", "); + call.append(automaticProps(GeneratedPart.unmapped("null"), children)); + call.append(")"); + return new ParseResult(call.text(), call.mappings(), nextIndex); + } GeneratedAssembler call = new GeneratedAssembler("globalThis.__nekoJsxFragment(", originalStart); for (int i = 0; i < children.size(); i++) { if (i > 0) { @@ -521,6 +802,51 @@ private ParseResult fragmentCall(int originalStart, List children return new ParseResult(call.text(), call.mappings(), nextIndex); } + /** + * 构造 automatic runtime 的 props 对象。 + * 无 children 时保留原 props({@code null} 或对象字面量); + * 有 children 时合并为 {@code { ...props, children: child | [..] }}。 + */ + private GeneratedPart automaticProps(GeneratedPart propsExpression, List children) { + if (children == null || children.isEmpty()) { + return propsExpression == null ? GeneratedPart.unmapped("null") : propsExpression; + } + GeneratedAssembler object = new GeneratedAssembler("{", -1); + boolean wroteField = false; + if (propsExpression != null) { + String propsText = propsExpression.text(); + if (propsText != null && !"null".equals(propsText)) { + String inner = propsText; + if (inner.startsWith("{") && inner.endsWith("}")) { + inner = inner.substring(1, inner.length() - 1).trim(); + } + if (!inner.isEmpty()) { + // 属性字段原样拷贝;mapping 在 props 层已不精确,保持文本正确优先 + object.append(inner); + wroteField = true; + } + } + } + if (wroteField) { + object.append(", "); + } + object.append("children: "); + if (children.size() == 1) { + object.append(children.get(0)); + } else { + object.append("["); + for (int i = 0; i < children.size(); i++) { + if (i > 0) { + object.append(", "); + } + object.append(children.get(i)); + } + object.append("]"); + } + object.append("}"); + return new GeneratedPart(object.text(), object.mappings()); + } + private GeneratedPart objectPart(List props) { GeneratedAssembler object = new GeneratedAssembler("{", -1); for (int i = 0; i < props.size(); i++) { diff --git a/common/src/main/java/com/tkisor/nekojs/core/compiler/NekoJsxLexer.java b/common/src/main/java/com/tkisor/nekojs/core/compiler/NekoJsxLexer.java index 959abb12..68488c19 100644 --- a/common/src/main/java/com/tkisor/nekojs/core/compiler/NekoJsxLexer.java +++ b/common/src/main/java/com/tkisor/nekojs/core/compiler/NekoJsxLexer.java @@ -6,8 +6,14 @@ public enum NekoJsxLexer implements NekoLexer { @Override public NekoTokenStream tokenize(NekoSourceFile source) { + return tokenize(source, false); + } + + public NekoTokenStream tokenize(NekoSourceFile source, boolean automaticRuntime) { boolean tsx = ".tsx".equals(source.extension()); - ScriptCompileResult compiled = tsx ? NekoJsxCompiler.compileTsx(source.path(), source.source()) : NekoJsxCompiler.compileJsx(source.path(), source.source()); + ScriptCompileResult compiled = tsx + ? NekoJsxCompiler.compileTsx(source.path(), source.source(), automaticRuntime) + : NekoJsxCompiler.compileJsx(source.path(), source.source(), automaticRuntime); return new NekoJsxTokenStream(source, tsx ? "tsx" : "jsx", compiled.code(), compiled.sourceMap()); } } diff --git a/common/src/main/java/com/tkisor/nekojs/core/compiler/NekoSourceMapBuilder.java b/common/src/main/java/com/tkisor/nekojs/core/compiler/NekoSourceMapBuilder.java index 3faf8c82..01a0a0e9 100644 --- a/common/src/main/java/com/tkisor/nekojs/core/compiler/NekoSourceMapBuilder.java +++ b/common/src/main/java/com/tkisor/nekojs/core/compiler/NekoSourceMapBuilder.java @@ -2,6 +2,7 @@ import com.google.gson.JsonArray; import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import com.tkisor.nekojs.core.fs.NekoJSPaths; import java.nio.file.Path; @@ -42,6 +43,19 @@ static Emitter emitter(Path file, String source) { return new Emitter(new NekoSourceMapBuilder(file, source)); } + /** + * Shifts all mappings down for generated lines inserted without source counterparts. + */ + static String prependUnmappedGeneratedLines(String sourceMap, int lineCount) { + if (sourceMap == null || sourceMap.isEmpty() || lineCount <= 0) { + return sourceMap; + } + JsonObject root = JsonParser.parseString(sourceMap).getAsJsonObject(); + String mappings = root.get("mappings").getAsString(); + root.addProperty("mappings", ";".repeat(lineCount) + mappings); + return root.toString(); + } + private void add(int generatedLine, int generatedColumn, int originalLine, int originalColumn) { mappings.add(new Mapping(generatedLine, generatedColumn, originalLine, originalColumn)); } diff --git a/common/src/main/java/com/tkisor/nekojs/core/compiler/NekoTypeScriptCompiler.java b/common/src/main/java/com/tkisor/nekojs/core/compiler/NekoTypeScriptCompiler.java index 043b71a6..388fe498 100644 --- a/common/src/main/java/com/tkisor/nekojs/core/compiler/NekoTypeScriptCompiler.java +++ b/common/src/main/java/com/tkisor/nekojs/core/compiler/NekoTypeScriptCompiler.java @@ -88,8 +88,12 @@ private String erase() { i = eraseStatement(i); continue; } + if ("import".equals(word) && hasInlineTypeSpecifier(end)) { + i = eraseInlineTypeSpecifiers(i, end); + continue; + } if ("export".equals(word) && typeExportAfter(end)) { - i = eraseStatement(i); + i = eraseExportTypeDeclaration(i); continue; } if ("implements".equals(word)) { @@ -111,6 +115,11 @@ private String erase() { i = end; continue; } + if (c == '@' && decoratorAt(i)) { + // 装饰器(@Component / @Log(...)):NekoJS 是脚本引擎非 TS 框架,不支持装饰器。 + // 明确报错而非透传成坏 JS(裸 @ 在 JS 里非法,GraalJS 会报错但信息不清晰)。 + throw unsupported("decorator (@)", i); + } if (c == '<' && genericTypeArgumentsAt(i)) { int end = matchingAngle(i); eraseRange(i, end + 1); @@ -331,21 +340,44 @@ private String generateEnumIife(String name, List members) { StringBuilder sb = new StringBuilder(); sb.append("var ").append(name).append("; (function (").append(name).append(") { "); long next = 0; + // 上一个数字成员的名字(用于上一个成员是「计算值」时,下一个无值成员的运行时自增 E["prev"] + 1)。 + // lastNumericKnown=true 表示 next 是编译期已知值(字面量数字);false 表示上一个数字成员是计算值。 + String lastNumericName = null; + boolean lastNumericKnown = true; for (EnumMember m : members) { String nm = m.name(); if (nm.isEmpty()) continue; if (!m.hasValue()) { - sb.append(name).append("[").append(name).append("[\"").append(nm).append("\"] = ").append(next).append("] = \"").append(nm).append("\"; "); - next++; + if (lastNumericName != null && !lastNumericKnown) { + // 上一个是计算值数字成员:运行时自增 E["prev"] + 1(编译期不知 prev 的值) + String expr = name + "[\"" + lastNumericName + "\"] + 1"; + sb.append(name).append("[").append(name).append("[\"").append(nm).append("\"] = " + expr + "] = \"").append(nm).append("\"; "); + lastNumericName = nm; + // 这个成员本身也是「计算」性质(值依赖运行时 prev),后续继续 +1 + } else { + // 已知数字基准(或首个成员从 0 起):用编译期字面量 + sb.append(name).append("[").append(name).append("[\"").append(nm).append("\"] = ").append(next).append("] = \"").append(nm).append("\"; "); + lastNumericName = nm; + lastNumericKnown = true; + next++; + } } else if (isStringLit(m.valueExpr())) { sb.append(name).append("[\"").append(nm).append("\"] = ").append(m.valueExpr()).append("; "); + // 字符串成员不提供数字基准 + lastNumericName = null; } else if (isNumberLit(m.valueExpr())) { long num; try { num = Long.parseLong(m.valueExpr()); } catch (NumberFormatException e) { num = next; } sb.append(name).append("[").append(name).append("[\"").append(nm).append("\"] = ").append(num).append("] = \"").append(nm).append("\"; "); next = num + 1; + lastNumericName = nm; + lastNumericKnown = true; } else { - sb.append(name).append("[\"").append(nm).append("\"] = ").append(m.valueExpr()).append("; "); + // 计算成员:值运行时才知。作为数字基准(TS 视计算 enum 成员为 number), + // 下一个无值成员用 E["thisMember"] + 1 运行时自增。 + sb.append(name).append("[").append(name).append("[\"").append(nm).append("\"] = ").append(m.valueExpr()).append("] = \"").append(nm).append("\"; "); + lastNumericName = nm; + lastNumericKnown = false; } } sb.append("})(").append(name).append(" || (").append(name).append(" = {}));"); @@ -428,6 +460,13 @@ private String generateNamespaceIife(String name, String body) { lastCopy = i + 6; i += 6; continue; + } else if (kwLen == -1) { + // interface/type:运行时无值,剥除 export 但不作为成员导出。 + // 其声明体已由 phase1 擦成空格,只剩裸 export + 名字残留,这里一并擦掉 export。 + cleaned.append(body, lastCopy, i).append(" "); + lastCopy = i + 6; + i += 6; + continue; } } i++; @@ -457,6 +496,11 @@ private int declKwLen(String body, int i) { if (bodyKeywordAt(body, i, "class")) return 5; if (bodyKeywordAt(body, i, "let")) return 3; if (bodyKeywordAt(body, i, "var")) return 3; + // interface/type:运行时不产生值成员,但 phase1 已把它们的声明体擦成空格, + // 这里返回长度以便剥除其 export 关键字,避免 IIFE 体内残留裸 export。 + // 注意:不把这类成员加入 members 列表(它们没有运行时绑定可导出)。 + if (bodyKeywordAt(body, i, "interface")) return -1; // 哨兵:剥 export 但不作为值成员 + if (bodyKeywordAt(body, i, "type")) return -1; return 0; } @@ -478,6 +522,8 @@ private void transformParameterProperties() { } private int transformOneConstructor(int start) { + ClassContext classContext = enclosingClassForTopLevelConstructor(start); + if (classContext == null) return start + 11; int i = nextOutNonWhitespace(start + 11); // 跳过 constructor if (i >= out.length() || out.charAt(i) != '(') return start + 11; int parenClose = matchOutParen(i); @@ -491,18 +537,172 @@ private int transformOneConstructor(int start) { while (j < out.length()) { char c = out.charAt(j); if (c == '\'' || c == '"') { j = skipOutString(j, c); continue; } + if (c == '`') { j = skipOutTemplate(j); continue; } + if (c == '/' && j + 1 < out.length() && out.charAt(j + 1) == '/') { j = skipOutLine(j + 2); continue; } + if (c == '/' && j + 1 < out.length() && out.charAt(j + 1) == '*') { j = skipOutBlock(j + 2); continue; } if (c == '{') { braceOpen = j; break; } if (c == ';') return j + 1; // 声明无体 j++; } if (braceOpen < 0) return parenClose + 1; + + int insertionPoint = braceOpen + 1; + if (classContext.derived()) { + int superCallEnd = findTopLevelSuperCallEnd(braceOpen); + if (superCallEnd < 0) { + throw new IllegalArgumentException("Cannot transform derived constructor parameter properties in " + + file + ": constructor has no legal top-level super(...) call."); + } + insertionPoint = superCallEnd; + } + out.replace(i + 1, parenClose, cleanedParams); // 擦除参数修饰符 int delta = cleanedParams.length() - (parenClose - i - 1); - braceOpen += delta; + insertionPoint += delta; // 参数替换发生在构造器体与插入点之前 StringBuilder assigns = new StringBuilder(); + if (classContext.derived()) assigns.append(';'); for (String name : assigned) assigns.append(" this.").append(name).append(" = ").append(name).append(";"); - out.insert(braceOpen + 1, assigns.toString()); - return braceOpen + 1 + assigns.length(); + out.insert(insertionPoint, assigns.toString()); + return insertionPoint + assigns.length(); + } + + private record ClassContext(boolean derived, int bodyOpen) {} + + /** 仅认 class 体第一层的 constructor,顺便判定 class 头是否含顶层 extends。 */ + private ClassContext enclosingClassForTopLevelConstructor(int constructorStart) { + ClassContext innermost = null; + int i = 0; + while (i < constructorStart) { + char c = out.charAt(i); + if (c == '\'' || c == '"') { i = skipOutString(i, c); continue; } + if (c == '`') { i = skipOutTemplate(i); continue; } + if (c == '/' && i + 1 < out.length() && out.charAt(i + 1) == '/') { i = skipOutLine(i + 2); continue; } + if (c == '/' && i + 1 < out.length() && out.charAt(i + 1) == '*') { i = skipOutBlock(i + 2); continue; } + if (isIdentifierStart(c) && outKeywordAt(i, "class")) { + int bodyOpen = findClassBodyOpen(i + 5); + if (bodyOpen < 0 || bodyOpen >= constructorStart) { i += 5; continue; } + int bodyClose = matchOutBrace(bodyOpen); + if (bodyClose >= constructorStart && classBodyDepthAt(bodyOpen, constructorStart) == 0) { + innermost = new ClassContext(classHeaderHasExtends(i + 5, bodyOpen), bodyOpen); + } + } + i++; + } + return innermost; + } + + private int findClassBodyOpen(int from) { + int paren = 0, bracket = 0, i = from; + while (i < out.length()) { + char c = out.charAt(i); + if (c == '\'' || c == '"') { i = skipOutString(i, c); continue; } + if (c == '`') { i = skipOutTemplate(i); continue; } + if (c == '/' && i + 1 < out.length() && out.charAt(i + 1) == '/') { i = skipOutLine(i + 2); continue; } + if (c == '/' && i + 1 < out.length() && out.charAt(i + 1) == '*') { i = skipOutBlock(i + 2); continue; } + if (c == '(') paren++; + else if (c == ')' && paren > 0) paren--; + else if (c == '[') bracket++; + else if (c == ']' && bracket > 0) bracket--; + else if (c == '{' && paren == 0 && bracket == 0) return i; + else if (c == ';' && paren == 0 && bracket == 0) return -1; + i++; + } + return -1; + } + + private boolean classHeaderHasExtends(int from, int bodyOpen) { + int paren = 0, bracket = 0, i = from; + while (i < bodyOpen) { + char c = out.charAt(i); + if (c == '\'' || c == '"') { i = skipOutString(i, c); continue; } + if (c == '`') { i = skipOutTemplate(i); continue; } + if (c == '/' && i + 1 < bodyOpen && out.charAt(i + 1) == '/') { i = skipOutLine(i + 2); continue; } + if (c == '/' && i + 1 < bodyOpen && out.charAt(i + 1) == '*') { i = skipOutBlock(i + 2); continue; } + if (c == '(') paren++; + else if (c == ')' && paren > 0) paren--; + else if (c == '[') bracket++; + else if (c == ']' && bracket > 0) bracket--; + else if (paren == 0 && bracket == 0 && isIdentifierStart(c) && outKeywordAt(i, "extends")) return true; + i++; + } + return false; + } + + private int classBodyDepthAt(int bodyOpen, int position) { + int depth = 0, i = bodyOpen + 1; + while (i < position) { + char c = out.charAt(i); + if (c == '\'' || c == '"') { i = skipOutString(i, c); continue; } + if (c == '`') { i = skipOutTemplate(i); continue; } + if (c == '/' && i + 1 < position && out.charAt(i + 1) == '/') { i = skipOutLine(i + 2); continue; } + if (c == '/' && i + 1 < position && out.charAt(i + 1) == '*') { i = skipOutBlock(i + 2); continue; } + if (c == '{') depth++; + else if (c == '}' && depth > 0) depth--; + i++; + } + return depth; + } + + /** 返回合法顶层独立 {@code super(...);} 语句的分号后一位。 */ + private int findTopLevelSuperCallEnd(int bodyOpen) { + int bodyClose = matchOutBrace(bodyOpen); + if (bodyClose < 0) return -1; + int brace = 0, paren = 0, bracket = 0, i = bodyOpen + 1; + while (i < bodyClose) { + char c = out.charAt(i); + if (c == '\'' || c == '"') { i = skipOutString(i, c); continue; } + if (c == '`') { i = skipOutTemplate(i); continue; } + if (c == '/') { + int skipped = skipOutSlash(i); + if (skipped != i) { i = skipped; continue; } + } + if (c == '{') brace++; + else if (c == '}' && brace > 0) brace--; + else if (c == '(') paren++; + else if (c == ')' && paren > 0) paren--; + else if (c == '[') bracket++; + else if (c == ']' && bracket > 0) bracket--; + else if (brace == 0 && paren == 0 && bracket == 0 && isIdentifierStart(c) && outKeywordAt(i, "super") + && standaloneStatementStart(i, bodyOpen)) { + int callOpen = nextOutTrivia(i + 5); + if (callOpen < bodyClose && out.charAt(callOpen) == '(') { + int callClose = matchOutParen(callOpen); + if (callClose >= 0 && callClose < bodyClose) { + int statementEnd = nextOutTrivia(callClose + 1); + if (statementEnd < bodyClose && out.charAt(statementEnd) == ';') return statementEnd + 1; + if (statementEnd == bodyClose) return callClose + 1; + if (hasOutLineTerminator(callClose + 1, statementEnd) + && !continuesSuperCallExpression(statementEnd)) return callClose + 1; + } + } + } + i++; + } + return -1; + } + + private boolean standaloneStatementStart(int keywordStart, int bodyOpen) { + int previous = keywordStart - 1; + boolean crossedLine = false; + while (previous > bodyOpen && Character.isWhitespace(out.charAt(previous))) { + crossedLine |= out.charAt(previous) == '\n' || out.charAt(previous) == '\r'; + previous--; + } + if (previous == bodyOpen || out.charAt(previous) == ';' || out.charAt(previous) == '}') return true; + return crossedLine && ".?=,+-*/%&|!<>([{".indexOf(out.charAt(previous)) < 0; + } + + private boolean hasOutLineTerminator(int from, int to) { + for (int i = from; i < to; i++) { + char c = out.charAt(i); + if (c == '\n' || c == '\r') return true; + } + return false; + } + + private boolean continuesSuperCallExpression(int tokenStart) { + char c = out.charAt(tokenStart); + return c == '.' || c == '?' || c == '[' || c == '(' || c == '`'; } private String cleanParamProperties(String params, List assigned) { @@ -527,6 +727,7 @@ private void processParam(String seg, StringBuilder cleaned, List assign int i = 0, n = seg.length(); int firstNonMod = -1; String paramName = null; + boolean parameterProperty = false; while (i < n) { while (i < n && Character.isWhitespace(seg.charAt(i))) i++; if (i >= n || !isIdentifierPart(seg.charAt(i))) break; @@ -534,12 +735,15 @@ private void processParam(String seg, StringBuilder cleaned, List assign while (i < n && isIdentifierPart(seg.charAt(i))) i++; String word = seg.substring(ws, i); boolean isMod = word.equals("public") || word.equals("private") || word.equals("protected") || word.equals("readonly"); - if (isMod) continue; + if (isMod) { + parameterProperty = true; + continue; + } firstNonMod = ws; paramName = word; break; } - if (firstNonMod >= 0 && paramName != null) { + if (firstNonMod >= 0 && paramName != null && parameterProperty) { assigned.add(paramName); cleaned.append(seg, firstNonMod, n); } else { @@ -567,6 +771,10 @@ private int matchOutParen(int open) { char c = out.charAt(i); if (c == '\'' || c == '"') { i = skipOutString(i, c); continue; } if (c == '`') { i = skipOutTemplate(i); continue; } + if (c == '/') { + int skipped = skipOutSlash(i); + if (skipped != i) { i = skipped; continue; } + } if (c == '(') depth++; else if (c == ')') { depth--; if (depth == 0) return i; } i++; @@ -590,7 +798,69 @@ private int skipOutBlock(int start) { while (start + 1 < out.length() && !(out.charAt(start) == '*' && out.charAt(start + 1) == '/')) start++; return Math.min(out.length(), start + 2); } + private int skipOutSlash(int slash) { + if (slash + 1 >= out.length()) return slash; + char next = out.charAt(slash + 1); + if (next == '/') return skipOutLine(slash + 2); + if (next == '*') return skipOutBlock(slash + 2); + if (!looksLikeOutRegexStart(slash)) return slash; + int i = slash + 1; + boolean inClass = false; + while (i < out.length()) { + char c = out.charAt(i); + if (c == '\\') { i += 2; continue; } + if (c == '[') inClass = true; + else if (c == ']') inClass = false; + else if (c == '/' && !inClass) { + i++; + while (i < out.length() && isIdentifierPart(out.charAt(i))) i++; + return i; + } + if (c == '\n' || c == '\r') return slash; + i++; + } + return out.length(); + } + private boolean looksLikeOutRegexStart(int slash) { + int previous = previousOutNonTrivia(slash - 1); + if (previous < 0) return true; + char c = out.charAt(previous); + if ("=(:,[!&|?;{}\n\r".indexOf(c) >= 0) return true; + if (!isIdentifierPart(c)) return false; + int start = previous; + while (start > 0 && isIdentifierPart(out.charAt(start - 1))) start--; + String word = out.substring(start, previous + 1); + return word.equals("return") || word.equals("throw") || word.equals("case") + || word.equals("delete") || word.equals("void") || word.equals("typeof") + || word.equals("instanceof") || word.equals("in") || word.equals("of"); + } + private int previousOutNonTrivia(int i) { + while (i >= 0) { + while (i >= 0 && Character.isWhitespace(out.charAt(i))) i--; + if (i > 0 && out.charAt(i) == '/' && out.charAt(i - 1) == '*') { + i -= 2; + while (i > 0 && !(out.charAt(i - 1) == '/' && out.charAt(i) == '*')) i--; + i -= 2; + continue; + } + int lineStart = i; + while (lineStart >= 0 && out.charAt(lineStart) != '\n' && out.charAt(lineStart) != '\r') lineStart--; + int comment = out.substring(lineStart + 1, i + 1).lastIndexOf("//"); + if (comment >= 0) { i = lineStart - 1; continue; } + return i; + } + return -1; + } private int nextOutNonWhitespace(int i) { while (i < out.length() && Character.isWhitespace(out.charAt(i))) i++; return i; } + private int nextOutTrivia(int i) { + while (i < out.length()) { + if (Character.isWhitespace(out.charAt(i))) { i++; continue; } + if (i + 1 < out.length() && out.charAt(i) == '/' && out.charAt(i + 1) == '/') { i = skipOutLine(i + 2); continue; } + if (i + 1 < out.length() && out.charAt(i) == '/' && out.charAt(i + 1) == '*') { i = skipOutBlock(i + 2); continue; } + break; + } + return i; + } private boolean outKeywordAt(int i, String kw) { if (i < 0 || i + kw.length() > out.length() || !out.substring(i, i + kw.length()).equals(kw)) return false; boolean before = i == 0 || !isIdentifierPart(out.charAt(i - 1)); @@ -603,6 +873,10 @@ private int matchOutBrace(int open) { char c = out.charAt(i); if (c == '\'' || c == '"') { i = skipOutString(i, c); continue; } if (c == '`') { i = skipOutTemplate(i); continue; } + if (c == '/') { + int skipped = skipOutSlash(i); + if (skipped != i) { i = skipped; continue; } + } if (c == '{') depth++; else if (c == '}') { depth--; if (depth == 0) return i; } i++; @@ -645,6 +919,18 @@ private int eraseStatement(int start) { return end; } + /** + * 擦除 {@code export type ...} / {@code export interface ...} 声明,仅擦类型声明本身, + * 不像 {@link #eraseStatement} 那样扫到换行/分号——因为 {@code export interface I { ... }} 后面 + * 可能紧跟其它语句(尤其 namespace 体内),用 statementEnd 会越界吞掉后续语句。 + * 用 statementOrBlockDeclarationEnd:interface 的 {...} 体匹配后即停。 + */ + private int eraseExportTypeDeclaration(int start) { + int end = statementOrBlockDeclarationEnd(start); + eraseRange(start, end); + return end; + } + private int eraseImplements(int start, int wordEnd) { int end = wordEnd; while (end < length) { @@ -688,15 +974,131 @@ private boolean typeExportAfter(int index) { return startsWithKeyword(i, "type") || startsWithKeyword(i, "interface"); } + /** import 语句的命名导入块里是否含内联 type 修饰符(TS 4.5+:{ real, type T })。*/ + private boolean hasInlineTypeSpecifier(int afterImport) { + int brace = nextNonWhitespace(afterImport); + // 只处理命名导入:import { ... } from ... + if (brace >= length || source.charAt(brace) != '{') return false; + int close = matchingCloseBracket(brace, '{', '}'); + if (close < 0) return false; + return findInlineTypeInBlock(brace + 1, close) >= 0; + } + + /** 在 {...} 块内查找顶层(逗号深度 0)的 `type X` 说明符,返回 type 关键字起点;找不到返回 -1。*/ + private int findInlineTypeInBlock(int from, int to) { + int depth = 0, i = from; + while (i < to) { + char c = source.charAt(i); + if (c == '\'' || c == '"') { i = skipString(i, c); continue; } + if (c == '`') { i = skipTemplate(i); continue; } + if (c == '{' || c == '[' || c == '(') { depth++; i++; continue; } + if (c == '}' || c == ']' || c == ')') { if (depth > 0) depth--; i++; continue; } + if (depth == 0 && isIdentifierStart(c)) { + int end = readIdentifierEnd(i + 1); + String word = source.substring(i, end); + if (word.equals("type")) { + // 确认是说明符:后面跟标识符(type X),而非属性名 type + int after = nextNonWhitespace(end); + if (after < to && isIdentifierStart(source.charAt(after))) return i; + } + } + i++; + } + return -1; + } + + /** 擦除 import 命名导入块里的所有内联 `type X` 说明符(含尾随逗号),保留值绑定与整条 import。*/ + private int eraseInlineTypeSpecifiers(int start, int afterImport) { + int brace = nextNonWhitespace(afterImport); + int close = matchingCloseBracket(brace, '{', '}'); + if (close < 0) return afterImport; + // 反复擦块内的内联 type 说明符 + int scanFrom = brace + 1; + int cursor = afterImport; + while (true) { + int typeStart = findInlineTypeInBlock(scanFrom, close); + if (typeStart < 0) break; + // type 说明符范围:从 typeStart 到下一个值绑定前(含尾随逗号与空白) + int specEnd = readIdentifierEnd(typeStart + 1); // type 关键字尾 + int nameStart = nextNonWhitespace(specEnd); + int nameEnd = readIdentifierEnd(nameStart + 1); // X 尾 + int eraseTo = nameEnd; + // 吃掉后面的逗号(如果有) + int afterComma = nextNonWhitespace(nameEnd); + if (afterComma < close && source.charAt(afterComma) == ',') { + eraseTo = afterComma + 1; + } else { + // 没有尾随逗号:吃掉前面的逗号(type X 是最后一个) + int before = previousNonWhitespace(typeStart - 1); + if (before >= brace + 1 && source.charAt(before) == ',') { + typeStart = before; + } + } + eraseRange(typeStart, eraseTo); + scanFrom = eraseTo; // eraseRange 保长度,close 不变 + } + return close + 1; + } + + /** 从 open 开始匹配配对的闭括号(跳过字符串/模板),找不到返回 -1。*/ + private int matchingCloseBracket(int open, char openCh, char closeCh) { + int depth = 0, i = open; + while (i < length) { + char c = source.charAt(i); + if (c == '\'' || c == '"') { i = skipString(i, c); continue; } + if (c == '`') { i = skipTemplate(i); continue; } + if (c == openCh) depth++; + else if (c == closeCh) { depth--; if (depth == 0) return i; } + i++; + } + return -1; + } + private boolean genericTypeArgumentsAt(int start) { int previous = previousNonWhitespace(start - 1); if (previous < 0) return false; char previousChar = source.charAt(previous); - if (!isIdentifierPart(previousChar) && previousChar != ')' && previousChar != ']') return false; + // 泛型实参/泛型箭头的合法前导:标识符/`)`/`]`(foo、(a)), + // 或赋值/参数/返回等上下文后的泛型箭头 (x) => …: + // `=`(const id = …)、`,`/`(`(作为函数实参)、`return`/`=>`/`{`/`;` 等语句起始。 + // 这些上下文里 `<…>(` 或 `<…> =>` 不可能是比较运算(比较不会紧跟 `(` 调用),故可放心擦除。 + boolean arrowContext = previousChar == '=' || previousChar == ',' + || previousChar == '(' || previousChar == '{' || previousChar == ';' + || previousChar == '\n' || previousChar == '\r' || previousChar == ':'; + if (!isIdentifierPart(previousChar) && previousChar != ')' && previousChar != ']' && !arrowContext) { + return false; + } + // 语句起始关键字(return/throw/yield/await 等)后的 ( 也是泛型箭头 + if (isIdentifierPart(previousChar)) { + int ws = previous; + while (ws > 0 && isIdentifierPart(source.charAt(ws - 1))) ws--; + String word = source.substring(ws, previous + 1); + if (word.equals("return") || word.equals("throw") || word.equals("yield") + || word.equals("await") || word.equals("default") || word.equals("case")) { + arrowContext = true; + } + } int close = matchingAngle(start); if (close < 0) return false; int next = nextNonWhitespace(close + 1); - return next < length && (source.charAt(next) == '(' || source.charAt(next) == '{'); + if (next >= length) return false; + char nextChar = source.charAt(next); + // ( 或 => —— 泛型箭头/泛型调用 + if (nextChar == '(' || nextChar == '{') return true; + if (nextChar == '=' && next + 1 < length && source.charAt(next + 1) == '>') return true; + // 非箭头上下文(标识符/) / ] 前导)下,( 仍是泛型调用(如 foo(x)) + if ((isIdentifierPart(previousChar) || previousChar == ')' || previousChar == ']') && nextChar == '(') { + return true; + } + // 泛型实参出现在表达式里但非紧接调用:foo, / foo) / foo] / foo; + // (典型场景:TSX 泛型组件 /> lowering 后的 Foo 表达式) + // 仅当前导是标识符/`)`/`]` 时认定;前导是 `,`/`=` 等属于箭头上下文(上面 arrowContext 已覆盖) + if ((isIdentifierPart(previousChar) || previousChar == ')' || previousChar == ']') + && (nextChar == ',' || nextChar == ')' || nextChar == ']' || nextChar == ';' + || nextChar == '\n' || nextChar == '\r')) { + return true; + } + return arrowContext && (nextChar == '(' || (nextChar == '=' && next + 1 < length && source.charAt(next + 1) == '>')); } private boolean typeAnnotationAt(int colon) { @@ -908,6 +1310,22 @@ private boolean nonNullAssertionAt(int bang) { return bang + 1 >= length || source.charAt(bang + 1) != '='; } + /** + * 是否装饰器({@code @Component} / {@code @Log(...)})。 + * 判据:{@code @} 后紧跟标识符起始字符,且前一非空白字符是声明起始位置 + *({@code \n} / {@code }} / {@code ;} / {@code {} 或文件首)。 + * 装饰器总是出现在声明前(类/方法/属性/参数),不会跟在值表达式后面。 + */ + private boolean decoratorAt(int at) { + if (at + 1 >= length) return false; + char next = source.charAt(at + 1); + if (!isIdentifierStart(next)) return false; + int previous = previousNonWhitespace(at - 1); + if (previous < 0) return true; // 文件首 + char pc = source.charAt(previous); + return pc == '\n' || pc == '\r' || pc == '}' || pc == ';' || pc == '{' || pc == ')'; + } + private int statementOrBlockDeclarationEnd(int start) { int bodyStart = -1; int i = start; @@ -1115,7 +1533,13 @@ private void eraseRange(int start, int end) { } private IllegalArgumentException unsupported(String syntax, int index) { - return new IllegalArgumentException("Unsupported TypeScript syntax '" + syntax + "' in " + file + " at " + position(index) + ". Use plain erasable TypeScript or register a compiler plugin for this syntax."); + String hint; + if (syntax.contains("decorator")) { + hint = "Decorators are not supported: NekoJS is a scripting engine, not a TypeScript framework. Replace the decorator with a plain function call (e.g. wrap your class/function with a helper instead of @Decorator)."; + } else { + hint = "Use plain erasable TypeScript or register a compiler plugin for this syntax."; + } + return new IllegalArgumentException("Unsupported TypeScript syntax '" + syntax + "' in " + file + " at " + position(index) + ". " + hint); } private String position(int index) { diff --git a/common/src/main/java/com/tkisor/nekojs/core/config/SandboxConfigLoader.java b/common/src/main/java/com/tkisor/nekojs/core/config/SandboxConfigLoader.java index 01d94f41..6a8cf33b 100644 --- a/common/src/main/java/com/tkisor/nekojs/core/config/SandboxConfigLoader.java +++ b/common/src/main/java/com/tkisor/nekojs/core/config/SandboxConfigLoader.java @@ -38,7 +38,7 @@ public SandboxConfig load(Path engineConfig) { " Emits direct source-focused script errors by default. Set false to log full verbose diagnostics and stack traces for analysis."); setupConfigEntry(config, "jsxAutomaticRuntime", false, - " When true, JSX scripts are automatically transformed by Babel using the automatic React runtime (jsx(...), jsxs(...), jsxDEV(...)) during ESM authoring. Its Babel parser considers the automatic jsx runtime import source URL from tsconfig/jsconfig, compilerOptions.jsxImportSource field."); + " Uses the automatic JSX runtime for .jsx/.tsx scripts: emits jsx()/Fragment() calls and imports from 'nekojs/jsx-runtime' instead of the classic globalThis.__nekoJsxFactory. Requires a jsx-runtime module (place one at nekojs/node_modules/nekojs/jsx-runtime.js)."); setupConfigEntry(config, "scriptMemberValidation", true, " Enables compile-time validation of global-binding and event-callback member accesses. Reports typos and missing members to the in-game error panel. Disable to skip all AST parsing overhead in production modpacks."); diff --git a/common/src/main/java/com/tkisor/nekojs/core/fs/JSConfigModel.java b/common/src/main/java/com/tkisor/nekojs/core/fs/JSConfigModel.java index cae37b86..af20b3ba 100644 --- a/common/src/main/java/com/tkisor/nekojs/core/fs/JSConfigModel.java +++ b/common/src/main/java/com/tkisor/nekojs/core/fs/JSConfigModel.java @@ -17,6 +17,18 @@ public class JSConfigModel { "./**/*.tsx" ); + /** + * Configures TypeScript's automatic JSX runtime for this model. + * Generated workspace configs remain classic by default; callers opt in explicitly. + */ + public void useAutomaticJsxRuntime() { + compilerOptions.jsx = "react-jsx"; + compilerOptions.jsxImportSource = "nekojs"; + compilerOptions.jsxFactory = null; + compilerOptions.jsxFragmentFactory = null; + compilerOptions.experimentalDecorators = false; + } + public static class CompilerOptions { public String target = "ESNext"; // ESM-first:脚本源码统一用 import/export(NekoEsmToUnifiedIrLowering 在编译期处理), @@ -30,6 +42,7 @@ public static class CompilerOptions { public String jsx = "react"; public String jsxFactory = "__nekoJsxFactory"; public String jsxFragmentFactory = "__nekoJsxFragment"; + public String jsxImportSource; public List lib = List.of("ESNext"); public boolean allowJs = true; @@ -43,7 +56,9 @@ public static class CompilerOptions { public boolean esModuleInterop = true; public boolean allowSyntheticDefaultImports = true; - public boolean experimentalDecorators = true; + // 装饰器:NekoJS 运行时不支持(脚本引擎非 TS 框架)。保持 false 避免误导 IDE/用户。 + // 遇到 @Decorator 会在擦除阶段清晰报错。如需装饰器语义,请用普通函数包装。 + public boolean experimentalDecorators = false; public boolean strict = true; public Map> paths = new LinkedHashMap<>(); diff --git a/common/src/main/java/com/tkisor/nekojs/core/module/NekoModulePipeline.java b/common/src/main/java/com/tkisor/nekojs/core/module/NekoModulePipeline.java index ab235edc..8e911b81 100644 --- a/common/src/main/java/com/tkisor/nekojs/core/module/NekoModulePipeline.java +++ b/common/src/main/java/com/tkisor/nekojs/core/module/NekoModulePipeline.java @@ -66,11 +66,13 @@ public NekoPreparedModule prepare(Path file, String rawSource) throws Exception if (language == NekoJavaScriptLanguagePlugin.INSTANCE) { return NekoPreparedModule.commonJs(rawSource, null); } - NekoCompileOutput compiled = compilationPipeline.compile(file, rawSource, extension, language); + NekoCompileOutput compiled = compilationPipeline.compile( + file, rawSource, extension, language, config.jsxAutomaticRuntime()); return NekoPreparedModule.commonJs(compiled.code(), compiled.program().sourceMap()); } - NekoCompileOutput compiled = compilationPipeline.compile(file, rawSource, extension, language); + NekoCompileOutput compiled = compilationPipeline.compile( + file, rawSource, extension, language, config.jsxAutomaticRuntime()); return prepareModule(compiled); } diff --git a/common/src/main/java/com/tkisor/nekojs/core/module/NekoModuleResolver.java b/common/src/main/java/com/tkisor/nekojs/core/module/NekoModuleResolver.java index 7e37e8bc..63e7f9d3 100644 --- a/common/src/main/java/com/tkisor/nekojs/core/module/NekoModuleResolver.java +++ b/common/src/main/java/com/tkisor/nekojs/core/module/NekoModuleResolver.java @@ -43,6 +43,9 @@ public NekoResolvedModule resolve(String parentPath, String specifier) throws IO if (specialKind != null) { return NekoResolvedModule.special(specifier, specialKind); } + if (!isFileSpecifier(specifier)) { + return resolveBareModule(specifier); + } Path parent = pathFromLoaderPath(parentPath); Path baseDirectory = Files.isDirectory(parent) ? parent : parent.getParent(); if (baseDirectory == null) { @@ -51,37 +54,60 @@ public NekoResolvedModule resolve(String parentPath, String specifier) throws IO return resolveFileModule(baseDirectory.resolve(specifier).normalize()); } + private NekoResolvedModule resolveBareModule(String specifier) throws IOException { + Path nodeModules = paths.nodeModules().toAbsolutePath().normalize(); + Path requested = nodeModules.resolve(specifier).normalize(); + try { + return resolveFileModule(requested, nodeModules); + } catch (IOException exception) { + if (isMissingModule(exception)) { + return NekoResolvedModule.special(specifier, NekoModuleKind.SPECIAL); + } + throw exception; + } + } + + private boolean isMissingModule(IOException exception) { + String message = exception.getMessage(); + return message != null && (message.startsWith("Module file does not exist:") + || message.startsWith("Cannot resolve module:")); + } + private NekoResolvedModule resolveFileModule(Path requested) throws IOException { - Path verified = verifyModulePath(requested); + return resolveFileModule(requested, null); + } + + private NekoResolvedModule resolveFileModule(Path requested, Path containmentRoot) throws IOException { + Path verified = verifyModulePath(requested, containmentRoot); if (Files.isRegularFile(verified)) { if (!isLoadableModule(verified)) { throw new IOException("Unsupported module file type: " + loaderPath(verified)); } - return moduleRecord(verified); + return moduleRecord(verified, containmentRoot); } if (hasExtension(verified)) { throw new IOException("Module file does not exist: " + loaderPath(verified)); } for (String extension : extensionsForCandidates()) { - Path candidate = verifyModulePath(verified.resolveSibling(verified.getFileName() + extension)); + Path candidate = verifyModulePath(verified.resolveSibling(verified.getFileName() + extension), containmentRoot); if (Files.isRegularFile(candidate)) { - return moduleRecord(candidate); + return moduleRecord(candidate, containmentRoot); } } if (Files.isDirectory(verified)) { for (String extension : extensionsForCandidates()) { - Path candidate = verifyModulePath(verified.resolve("index" + extension)); + Path candidate = verifyModulePath(verified.resolve("index" + extension), containmentRoot); if (Files.isRegularFile(candidate)) { - return moduleRecord(candidate); + return moduleRecord(candidate, containmentRoot); } } } throw new IOException("Cannot resolve module: " + loaderPath(verified)); } - private NekoResolvedModule moduleRecord(Path path) throws IOException { - Path verified = verifyModulePath(path); + private NekoResolvedModule moduleRecord(Path path, Path containmentRoot) throws IOException { + Path verified = verifyModulePath(path, containmentRoot); Path canonical = verified.toRealPath(); NekoModuleKind kind = isJson(canonical) ? NekoModuleKind.JSON : NekoModuleKind.SCRIPT; return new NekoResolvedModule(canonical, loaderPath(canonical), loaderPath(canonical.getParent()), null, kind); @@ -92,12 +118,22 @@ private List extensionsForCandidates() { } private Path verifyModulePath(Path path) throws IOException { + return verifyModulePath(path, null); + } + + private Path verifyModulePath(Path path, Path containmentRoot) throws IOException { Path verified = paths.verifyInsideGameDir(path); + if (containmentRoot != null && !verified.toAbsolutePath().normalize().startsWith(containmentRoot)) { + throw new IOException("Bare module path escapes node_modules: " + loaderPath(verified)); + } if (Files.exists(verified)) { Path realPath = verified.toRealPath(); if (!realPath.startsWith(paths.gameDir().normalize().toAbsolutePath())) { throw new IOException("Symlink escape detected: " + realPath); } + if (containmentRoot != null && !realPath.startsWith(containmentRoot.toRealPath())) { + throw new IOException("Bare module path escapes node_modules: " + loaderPath(realPath)); + } } return verified; } @@ -129,7 +165,7 @@ private NekoModuleKind specialKind(String specifier) throws IOException { if (specifier.startsWith("node:") || isBuiltinSpecifier(specifier)) { return NekoModuleKind.BUILTIN; } - return NekoModuleKind.SPECIAL; + return null; } private boolean isJavaSpecifier(String specifier) { diff --git a/common/src/test/java/com/tkisor/nekojs/api/JavaMemberIndexTest.java b/common/src/test/java/com/tkisor/nekojs/api/JavaMemberIndexTest.java index 76b41160..81ded934 100644 --- a/common/src/test/java/com/tkisor/nekojs/api/JavaMemberIndexTest.java +++ b/common/src/test/java/com/tkisor/nekojs/api/JavaMemberIndexTest.java @@ -5,70 +5,146 @@ import com.tkisor.nekojs.api.annotation.RemapByPrefix; import org.junit.jupiter.api.Test; -import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertSame; - +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link JavaMemberIndex} 测试:成员名集合(含与 propertyMembersOf 的历史不对称)、拼写建议、 + * 注解驱动的 remapName 优先级与 fall-through marker 模式。 + */ class JavaMemberIndexTest { - @Test - void returnsCallerFallThroughMarkerForUnmappedMember() throws Exception { - Method plain = Fixture.class.getMethod("plain"); - String fallThrough = new String("fall-through"); + // ==================== 成员名集合 ==================== - assertSame(fallThrough, JavaMemberIndex.remapName(plain, "hidden", fallThrough)); + public static class FakeEvent { + public String getName() { return "neo"; } + public boolean isAlive() { return true; } + public String greet(String who) { return "hi " + who; } + public String getNull() { return null; } + public int count = 7; } @Test - void preservesNameWhenPrefixWouldProduceAnEmptyBinding() throws Exception { - Method getter = PrefixOnly.class.getMethod("get"); + void allMembersOfContainsMethodsPropertiesAndFields() { + Set all = JavaMemberIndex.allMembersOf(FakeEvent.class); + // 方法名(原样) + assertTrue(all.contains("getName"), all.toString()); + assertTrue(all.contains("isAlive"), all.toString()); + assertTrue(all.contains("greet"), all.toString()); + assertTrue(all.contains("getNull"), all.toString()); + // 无参 getter 属性名(getEntity→entity) + assertTrue(all.contains("name"), all.toString()); + assertTrue(all.contains("alive"), all.toString()); + assertTrue(all.contains("null"), all.toString()); + // public 字段 + assertTrue(all.contains("count"), all.toString()); + // getClass 被显式排除 + assertFalse(all.contains("getClass"), all.toString()); + } - assertEquals("get", JavaMemberIndex.remapName(getter, "hidden", getter.getName())); + @Test + void propertyMembersOfExcludesObjectClassMethods() { + Set prop = JavaMemberIndex.propertyMembersOf(FakeEvent.class); + assertTrue(prop.contains("getName"), prop.toString()); + assertTrue(prop.contains("name"), prop.toString()); + assertTrue(prop.contains("count"), prop.toString()); + // 关键差异:propertyMembersOf 排除 Object.class 声明的方法 + assertFalse(prop.contains("equals"), prop.toString()); + assertFalse(prop.contains("hashCode"), prop.toString()); + assertFalse(prop.contains("toString"), prop.toString()); } @Test - void appliesMemberMappingsBeforePrefixMappings() throws Exception { - Method renamed = Fixture.class.getMethod("getRenamed"); - Method prefixed = Fixture.class.getMethod("getValue"); - Field classPrefixed = Fixture.class.getField("isReady"); - - assertEquals("renamed", JavaMemberIndex.remapName(renamed, "hidden", "fall-through")); - assertEquals("Value", JavaMemberIndex.remapName(prefixed, "hidden", "fall-through")); - assertEquals("Ready", JavaMemberIndex.remapName(classPrefixed, "hidden", "fall-through")); + void allMembersOfDoesNotExcludeObjectClassMethods() { + // 历史不对称:allMembersOf(全局绑定校验用)不排除 Object.class 方法 + Set all = JavaMemberIndex.allMembersOf(FakeEvent.class); + assertTrue(all.contains("equals"), all.toString()); + assertTrue(all.contains("hashCode"), all.toString()); + assertTrue(all.contains("toString"), all.toString()); + assertFalse(all.contains("getClass"), all.toString()); } @Test - void returnsCallerHideMarkerForHiddenMember() throws Exception { - Method hidden = Fixture.class.getMethod("hidden"); - String hideMarker = new String("hidden"); + void suggestMemberFindsCloseWithinDistance() { + Set members = Set.of("getName", "isAlive", "greet"); + assertEquals("getName", JavaMemberIndex.suggestMember(members, "getNam")); // 距离 1 + assertEquals("greet", JavaMemberIndex.suggestMember(members, "grete")); // 距离 1 + assertNull(JavaMemberIndex.suggestMember(members, "zzzzzzzzz")); // 超距 + } - assertSame(hideMarker, JavaMemberIndex.remapName(hidden, hideMarker, "fall-through")); + @Test + void unknownMemberMessageIncludesClassAndSuggestion() { + String msg = JavaMemberIndex.unknownMemberMessage(FakeEvent.class, "nam"); + assertTrue(msg.contains("FakeEvent"), msg); + assertTrue(msg.contains("'nam'"), msg); + assertTrue(msg.contains("Did you mean"), msg); + assertTrue(msg.contains("name"), msg); } - @RemapByPrefix({"get", "is"}) - static class Fixture { - public String isReady; + // ==================== remapName 注解优先级 ==================== - public void plain() { - } + /** 类级 {@code @RemapByPrefix({"get"})};各成员覆盖不同优先级。 */ + @RemapByPrefix({"get"}) + public static class Annotated { + @HideFromJS + public void hidden() {} - @Remap("renamed") - public void getRenamed() { - } + @Remap("customName") + public void remapped() {} - public void getValue() { - } + @RemapByPrefix({"do"}) // 成员级覆盖类级 + public void doThing() {} - @HideFromJS - public void hidden() { - } + public void getFoo() {} // 走类级 get→Foo + } + + @Test + void remapNameHideFromJsReturnsHideMarker() throws NoSuchMethodException { + Method m = Annotated.class.getMethod("hidden"); + assertEquals("HIDE", JavaMemberIndex.remapName(m, "HIDE", m.getName())); + assertNull(JavaMemberIndex.remapName(m, null, m.getName())); + } + + @Test + void remapNameRemapAnnotationWins() throws NoSuchMethodException { + Method m = Annotated.class.getMethod("remapped"); + assertEquals("customName", JavaMemberIndex.remapName(m, null, m.getName())); + } + + @Test + void remapNameMemberLevelRemapByPrefixOverridesClass() throws NoSuchMethodException { + Method m = Annotated.class.getMethod("doThing"); + assertEquals("Thing", JavaMemberIndex.remapName(m, null, m.getName())); + } + + @Test + void remapNameClassLevelRemapByPrefix() throws NoSuchMethodException { + Method m = Annotated.class.getMethod("getFoo"); + assertEquals("Foo", JavaMemberIndex.remapName(m, null, m.getName())); } - @RemapByPrefix("get") - static class PrefixOnly { - public void get() { - } + @Test + void remapNameReturnsCallerMarkerWhenNoMappingApplies() throws NoSuchMethodException { + Method m = FakeEvent.class.getMethod("greet", String.class); + String fallThrough = new String("FALL_THROUGH"); + assertSame(fallThrough, JavaMemberIndex.remapName(m, null, fallThrough)); + } + + /** name 恰等于前缀时不产生空 JS binding。 */ + public static class PrefixEdge { + @RemapByPrefix({"ge"}) + public void ge() {} + } + + @Test + void remapNameRejectsEmptyPrefixStrip() throws NoSuchMethodException { + Method m = PrefixEdge.class.getMethod("ge"); + assertEquals("ge", JavaMemberIndex.remapName(m, null, m.getName())); } } diff --git a/common/src/test/java/com/tkisor/nekojs/api/data/BindingPriorityTest.java b/common/src/test/java/com/tkisor/nekojs/api/data/BindingPriorityTest.java new file mode 100644 index 00000000..9dfa1504 --- /dev/null +++ b/common/src/test/java/com/tkisor/nekojs/api/data/BindingPriorityTest.java @@ -0,0 +1,36 @@ +package com.tkisor.nekojs.api.data; + +import com.tkisor.nekojs.api.ScriptType; +import com.tkisor.nekojs.testfixture.TestPlatformInit; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BindingPriorityTest { + + @BeforeAll + static void initPlatform() { + TestPlatformInit.ensureInitialized(); + } + + @Test + void secondRegistrationDoesNotOverwrite() { + var reg = new BindingRegistry.BindingRegistryImpl(ScriptType.SERVER); + assertTrue(reg.register("dup", "first"), "first registration should succeed"); + assertFalse(reg.register("dup", "second"), "duplicate registration must not overwrite"); + var view = reg.viewRegistered(); + assertEquals("first", view.get("dup").value(), + "original value must be retained after rejected duplicate"); + } + + @Test + void differentNamesBothRegister() { + var reg = new BindingRegistry.BindingRegistryImpl(ScriptType.SERVER); + assertTrue(reg.register("a", 1)); + assertTrue(reg.register("b", 2)); + assertEquals(2, reg.viewRegistered().size()); + } +} diff --git a/common/src/test/java/com/tkisor/nekojs/core/compiler/CompilerExecutionAssertions.java b/common/src/test/java/com/tkisor/nekojs/core/compiler/CompilerExecutionAssertions.java new file mode 100644 index 00000000..2dbbc9e0 --- /dev/null +++ b/common/src/test/java/com/tkisor/nekojs/core/compiler/CompilerExecutionAssertions.java @@ -0,0 +1,71 @@ +package com.tkisor.nekojs.core.compiler; + +import graal.graalvm.polyglot.Context; +import graal.graalvm.polyglot.HostAccess; +import graal.graalvm.polyglot.Source; +import graal.graalvm.polyglot.Value; +import graal.graalvm.polyglot.io.IOAccess; + +final class CompilerExecutionAssertions { + private static final String AUTOMATIC_RUNTIME_IMPORT = + "(?m)\\Aimport \\{ [^\\r\\n]+ } from 'nekojs/jsx-runtime';\\R"; + + private CompilerExecutionAssertions() {} + + static void parse(String code) { + try (Context context = restrictedContext()) { + context.parse(source(code)); + } + } + + static Evaluation eval(String code) { + Context context = restrictedContext(); + try { + return new Evaluation(context, context.eval(source(code))); + } catch (RuntimeException | Error failure) { + context.close(); + throw failure; + } + } + + static Evaluation evalAutomatic(String code, String runtimeBindings) { + String executable = code.replaceFirst(AUTOMATIC_RUNTIME_IMPORT, ""); + if (executable.equals(code)) { + throw new AssertionError("Expected the automatic JSX runtime import at the start of output: " + code); + } + return eval(runtimeBindings + "\n" + executable); + } + + private static Context restrictedContext() { + return Context.newBuilder("js") + .allowHostAccess(HostAccess.NONE) + .allowHostClassLookup(className -> false) + .allowIO(IOAccess.NONE) + .allowCreateThread(false) + .allowCreateProcess(false) + .build(); + } + + private static Source source(String code) { + return Source.create("js", code); + } + + static final class Evaluation implements AutoCloseable { + private final Context context; + private final Value value; + + private Evaluation(Context context, Value value) { + this.context = context; + this.value = value; + } + + Value value() { + return value; + } + + @Override + public void close() { + context.close(); + } + } +} diff --git a/common/src/test/java/com/tkisor/nekojs/core/compiler/NekoCompilationPipelineJsxRuntimeTest.java b/common/src/test/java/com/tkisor/nekojs/core/compiler/NekoCompilationPipelineJsxRuntimeTest.java new file mode 100644 index 00000000..e444b9ed --- /dev/null +++ b/common/src/test/java/com/tkisor/nekojs/core/compiler/NekoCompilationPipelineJsxRuntimeTest.java @@ -0,0 +1,39 @@ +package com.tkisor.nekojs.core.compiler; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class NekoCompilationPipelineJsxRuntimeTest { + + @Test + void sharedJsxPluginAndPipelineUseTheRuntimeRequestedForEachCompilation() throws Exception { + NekoCompilationPipeline pipeline = new NekoCompilationPipeline(); + NekoLanguagePlugin jsx = NekoJsxLanguagePlugin.INSTANCE; + + String classicBefore = pipeline.compile( + Path.of("classic-before.jsx"), "const node =
", ".jsx", jsx).code(); + String automaticTsx = pipeline.compile( + Path.of("automatic.tsx"), "const node: unknown =
", ".tsx", jsx, true).code(); + String classicAfter = pipeline.compile( + Path.of("classic-after.jsx"), "const node =
", ".jsx", jsx).code(); + + assertClassic(classicBefore); + assertAutomatic(automaticTsx); + assertClassic(classicAfter); + } + + private static void assertClassic(String output) { + assertTrue(output.contains("globalThis.__nekoJsxFactory("), "expected classic JSX output: " + output); + assertFalse(output.contains("from 'nekojs/jsx-runtime'"), "classic output must not import the automatic runtime: " + output); + } + + private static void assertAutomatic(String output) { + assertTrue(output.contains("from 'nekojs/jsx-runtime'"), "expected automatic JSX runtime import: " + output); + assertTrue(output.contains("jsx("), "expected automatic JSX call: " + output); + assertFalse(output.contains("globalThis.__nekoJsxFactory("), "automatic output must not use the classic factory: " + output); + } +} diff --git a/common/src/test/java/com/tkisor/nekojs/core/compiler/NekoJsxAttributesTest.java b/common/src/test/java/com/tkisor/nekojs/core/compiler/NekoJsxAttributesTest.java new file mode 100644 index 00000000..dd9321bd --- /dev/null +++ b/common/src/test/java/com/tkisor/nekojs/core/compiler/NekoJsxAttributesTest.java @@ -0,0 +1,64 @@ +package com.tkisor.nekojs.core.compiler; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class NekoJsxAttributesTest { + + @Test + void evaluatesBooleanExpressionAndSpreadAttributesInSourceOrder() { + String out = NekoJsxCompiler.compileJsx(Path.of("attributes.jsx"), """ + const base = { first: 1, overridden: 'base' }; + globalThis.result = ; + """).code(); + String runtime = "globalThis.Widget = 'Widget';\n" + + "globalThis.__nekoJsxFactory = (type, props) => ({ type, props });\n"; + + try (CompilerExecutionAssertions.Evaluation evaluation = CompilerExecutionAssertions.eval( + runtime + out + "\nJSON.stringify(globalThis.result)")) { + assertEquals( + "{\"type\":\"Widget\",\"props\":{\"enabled\":true,\"value\":3,\"first\":1,\"overridden\":\"explicit\"}}", + evaluation.value().asString() + ); + } + } + + @Test + void evaluatesInterleavedAttributesAndSpreadsFromLeftToRight() { + String out = NekoJsxCompiler.compileJsx(Path.of("attribute-order.jsx"), """ + const trace = []; + const mark = (name, value) => { trace.push(name); return value; }; + globalThis.result = ; + globalThis.trace = trace.join(','); + """).code(); + String runtime = "globalThis.Widget = 'Widget';\n" + + "globalThis.__nekoJsxFactory = (type, props) => ({ type, props });\n"; + + try (CompilerExecutionAssertions.Evaluation evaluation = CompilerExecutionAssertions.eval( + runtime + out + "\nJSON.stringify({ result: globalThis.result, trace: globalThis.trace })")) { + assertEquals( + "{\"result\":{\"type\":\"Widget\",\"props\":{\"x\":\"one\",\"y\":3,\"z\":4}},\"trace\":\"first,spread-one,middle,spread-two\"}", + evaluation.value().asString() + ); + } + } + + @Test + void lowersNestedJsxInsideAttributeExpression() { + String out = NekoJsxCompiler.compileJsx(Path.of("nested-attribute.jsx"), + "globalThis.result = text}/>;").code(); + String runtime = "globalThis.Wrapper = 'Wrapper';\n" + + "globalThis.__nekoJsxFactory = (type, props, ...children) => ({ type, props, children });\n"; + + try (CompilerExecutionAssertions.Evaluation evaluation = CompilerExecutionAssertions.eval( + runtime + out + "\nJSON.stringify(globalThis.result)")) { + assertEquals( + "{\"type\":\"Wrapper\",\"props\":{\"child\":{\"type\":\"span\",\"props\":null,\"children\":[\"text\"]}},\"children\":[]}", + evaluation.value().asString() + ); + } + } +} diff --git a/common/src/test/java/com/tkisor/nekojs/core/compiler/NekoJsxCompilerTest.java b/common/src/test/java/com/tkisor/nekojs/core/compiler/NekoJsxCompilerTest.java new file mode 100644 index 00000000..76d3ac95 --- /dev/null +++ b/common/src/test/java/com/tkisor/nekojs/core/compiler/NekoJsxCompilerTest.java @@ -0,0 +1,295 @@ +package com.tkisor.nekojs.core.compiler; + +import com.google.gson.JsonArray; +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link NekoJsxCompiler#compileJsx} 的 lowering 正确性测试。 + * 覆盖 classic runtime 输出形态、HTML 实体解码、命名空间标签、泛型组件、子元素处理等。 + * 运行时合法性(factory 调用可被 graaljs eval)由游戏内端到端验证。 + */ +class NekoJsxCompilerTest { + + private static String compile(String src) { + return NekoJsxCompiler.compileJsx(Path.of("test.jsx"), src).code(); + } + + @Test + void lowersBasicElementToFactoryCall() { + String out = compile("const el =
hi
"); + assertTrue(out.contains("globalThis.__nekoJsxFactory("), "须 lowering 成 factory 调用: " + out); + assertTrue(out.contains("'div'"), "小写标签须作字符串: " + out); + assertTrue(out.contains("class: "), "属性键须保留: " + out); + assertTrue(out.contains("hi"), "文本子节点须保留: " + out); + } + + @Test + void lowersSelfClosingAndFragment() { + String out1 = compile("const a =
"); + assertTrue(out1.contains("globalThis.__nekoJsxFactory(") && out1.contains("'br'"), "自闭合须正常: " + out1); + String out2 = compile("const f = <>ac"); + assertTrue(out2.contains("globalThis.__nekoJsxFragment("), "fragment 须用 fragment factory: " + out2); + } + + @Test + void decodesHtmlEntitiesInText() { + // JSX 文本里的 HTML 实体须解码(&→&, <→<, >→>, '→', "→") + String out = compile("const el =

A & B < C > D

"); + // 解码后文本应是 "A & B < C > D" + assertTrue(out.contains("A & B"), "& 须解码为 &: " + out); + assertTrue(out.contains("B < C"), "< 须解码为 <: " + out); + assertTrue(out.contains("C > D"), "> 须解码为 >: " + out); + assertFalse(out.contains("&"), "不应残留 &: " + out); + assertFalse(out.contains("<"), "不应残留 <: " + out); + } + + @Test + void decodesNumericAndQuoteEntities() { + String out = compile("const el =

it's "ok"

"); + // ' 解码为 ',因整体被包进单引号字符串,' 会被转义成 \' + assertFalse(out.contains("'"), "' 须解码(不再残留实体): " + out); + assertTrue(out.contains("\\'s "), "' 解码为 '(字符串内转义为 \\') : " + out); + assertTrue(out.contains("\"ok\""), "" 须解码为 \": " + out); + } + + @Test + void doesNotDecodeEntitiesInStringAttributes() { + // 字符串属性值里的实体是字面量(与 React 行为一致),不作解码 + String out = compile("const el = "); + // 属性字符串原样保留(带引号) + assertTrue(out.contains("A & B"), "字符串属性里的实体应原样保留: " + out); + } + + @Test + void preservesNamespaceTagNames() { + // 命名空间标签 —— 名称整段作为字符串传给 factory。 + String out = compile("const el = "); + assertTrue(out.contains("'svg:rect'"), "命名空间标签须作字符串字面量: " + out); + assertTrue(out.contains("'xlink:href': \"#shape\""), "命名空间属性须使用合法属性键: " + out); + + String componentOut = compile("const el = "); + assertTrue(componentOut.contains("__nekoJsxFactory(Foo.Bar, null)"), + "成员组件表达式仍须作为表达式透传: " + componentOut); + } + + @Test + void preservesGenericComponentSyntaxInTsx() { + // 泛型组件 /> —— TSX:JSX 层把 Foo 透传,泛型 由 TS 擦除阶段处理 + // 这里测 compileTsx 的完整输出:Foo 保留为组件引用, 被擦除 + String src = "const el = />"; + String out = NekoJsxCompiler.compileTsx(Path.of("test.tsx"), src).code(); + assertTrue(out.contains("Foo"), "组件名 Foo 须保留: " + out); + assertFalse(out.contains(""), "泛型 须被 TS 擦除: " + out); + } + + @Test + void expressionChildrenAndSpreadAttributes() { + String out = compile("const el =
    {items.map(i =>
  • {i}
  • )}
"); + assertTrue(out.contains("...{class: 'x'}"), "spread 属性须保留: " + out); + assertTrue(out.contains("items.map"), "表达式子节点须保留: " + out); + } + + @Test + void tsxGenericComponentWithObjectTypePassesComponentValueToFactory() { + String source = "const Foo = () => 'component'; const value = />; globalThis.result = value"; + String out = NekoJsxCompiler.compileTsx(Path.of("generic-component.tsx"), source).code(); + String runtime = "globalThis.__nekoJsxFactory = (type, props) => typeof type;\n"; + + try (CompilerExecutionAssertions.Evaluation evaluation = + CompilerExecutionAssertions.eval(runtime + out + "\nglobalThis.result")) { + assertEquals("function", evaluation.value().asString(), + "generic component must reach factory as Foo value, not a string: " + out); + } + } + + @Test + void tsxGenericArrowsAreNotParsedAsJsx() { + String[] sources = { + "const id = (x: T): T => x; globalThis.result = id(1)", + "const id = (x: T) => x; globalThis.result = id(1)" + }; + for (String source : sources) { + String out = NekoJsxCompiler.compileTsx(Path.of("generic-arrow.tsx"), source).code(); + try (CompilerExecutionAssertions.Evaluation evaluation = CompilerExecutionAssertions.eval(out + "\nglobalThis.result")) { + assertEquals(1, evaluation.value().asInt(), "generic arrow must compile as TypeScript, not JSX: " + out); + } + } + } + + @Test + void expressionChildRegexCharacterClassesDoNotCloseJsxBrace() { + String[] sources = { + "globalThis.result =
{/[}]/.test('}')}
", + "globalThis.result =
{/}/.test('}')}
" + }; + for (String source : sources) { + String out = compile(source); + String runtime = "globalThis.__nekoJsxFactory = (type, props, child) => child;\n"; + try (CompilerExecutionAssertions.Evaluation evaluation = CompilerExecutionAssertions.eval(runtime + out + "\nglobalThis.result")) { + assertTrue(evaluation.value().asBoolean(), "regex expression child must retain its closing brace: " + out); + } + } + } + + @Test + void expressionChildRegexAfterControlParenDoesNotCloseJsxBrace() { + String source = "globalThis.result =
{(() => { if (true) /}/.test('}'); return true; })()}
"; + String out = compile(source); + String runtime = "globalThis.__nekoJsxFactory = (type, props, child) => child;\n"; + + try (CompilerExecutionAssertions.Evaluation evaluation = + CompilerExecutionAssertions.eval(runtime + out + "\nglobalThis.result")) { + assertTrue(evaluation.value().asBoolean(), + "regex following a control-condition parenthesis must retain its closing brace: " + out); + } + } + + @Test + void expressionChildRegexAfterControlKeywordCommentDoesNotCloseJsxBrace() { + String source = "globalThis.result =
{(() => { if /*comment*/ (true) /}/.test('}'); return true; })()}
"; + String out = compile(source); + String runtime = "globalThis.__nekoJsxFactory = (type, props, child) => child;\n"; + + try (CompilerExecutionAssertions.Evaluation evaluation = + CompilerExecutionAssertions.eval(runtime + out + "\nglobalThis.result")) { + assertTrue(evaluation.value().asBoolean(), + "regex following a commented control keyword must retain its closing brace: " + out); + } + } + + @Test + void expressionChildDivisionAndNestedJsxClosingRemainIntact() { + String division = compile("globalThis.result =
{10 / 2}
"); + String nested = compile("globalThis.result =
{{10 / 2}}
"); + String runtime = "globalThis.__nekoJsxFactory = (type, props, child) => child;\n"; + + try (CompilerExecutionAssertions.Evaluation evaluation = + CompilerExecutionAssertions.eval(runtime + division + "\nglobalThis.result")) { + assertEquals(5, evaluation.value().asInt(), + "division must not be classified as a regex: " + division); + } + assertTrue(nested.contains("__nekoJsxFactory('span'"), + "nested JSX must still lower and consume its closing tag: " + nested); + } + + @Test + void automaticRuntimeImportLeavesGeneratedLineZeroUnmappedAndShiftsOriginalMappings() { + String automaticMap = NekoJsxCompiler.compileJsx(Path.of("automatic.jsx"), "const el =
", true).sourceMap(); + String classicMap = NekoJsxCompiler.compileJsx(Path.of("classic.jsx"), "const el =
").sourceMap(); + + String[] automaticLines = JsonParser.parseString(automaticMap).getAsJsonObject() + .get("mappings").getAsString().split(";", -1); + assertEquals("", automaticLines[0], + "runtime import line must have no original-source mapping: " + automaticMap); + assertFalse(automaticLines[1].isEmpty(), + "first original-source mapping must start after the injected import: " + automaticMap); + + String[] classicLines = JsonParser.parseString(classicMap).getAsJsonObject() + .get("mappings").getAsString().split(";", -1); + assertFalse(classicLines[0].isEmpty(), + "classic output must keep its first original-source mapping on generated line zero: " + classicMap); + } + + @Test + void automaticRuntimeImportsOnceForNestedTransformAndNotForNonJsxSource() { + String nested = NekoJsxCompiler.compileJsx( + Path.of("nested.jsx"), "const el =
{}
", true).code(); + assertEquals(1, nested.split("from 'nekojs/jsx-runtime'", -1).length - 1, + "nested JSX lowering must emit only the outer runtime import: " + nested); + + String nonJsx = NekoJsxCompiler.compileJsx(Path.of("plain.jsx"), "const value = 1;", true).code(); + assertFalse(nonJsx.contains("nekojs/jsx-runtime"), + "automatic runtime must not inject an import when no JSX was lowered: " + nonJsx); + } + + @Test + void automaticRuntimeEmitsJsxCallAndImport() { + // 自动 runtime:输出 jsx(type, props, key) 调用,并在文件头注入 import { jsx, Fragment } + String src = "const el =
hi
"; + String out = NekoJsxCompiler.compileJsx(Path.of("test.jsx"), src, true).code(); + assertTrue(out.contains("import { jsx"), "自动 runtime 须注入 jsx import: " + out); + assertTrue(out.contains("from 'nekojs/jsx-runtime'"), "须从 nekojs/jsx-runtime 导入: " + out); + assertTrue(out.contains("jsx("), "须用 jsx() 调用而非 __nekoJsxFactory: " + out); + assertFalse(out.contains("__nekoJsxFactory"), "自动 runtime 不应再用 classic factory: " + out); + } + + @Test + void automaticRuntimeFragmentUsesFragmentImport() { + // 自动 runtime 下 Fragment 是类型值:jsx(Fragment, { children: ... }) + String out = NekoJsxCompiler.compileJsx(Path.of("test.jsx"), "const f = <>x", true).code(); + assertTrue(out.contains("import {"), "须有 import: " + out); + assertTrue(out.contains("Fragment"), "Fragment 须在 import 中: " + out); + assertTrue(out.contains("jsx(Fragment,"), "fragment 须把 Fragment 作为 type 传给 jsx: " + out); + assertFalse(out.contains("Fragment("), "Fragment 不能当函数调用: " + out); + } + + @Test + void classicRuntimeRemainsDefault() { + // 不传 automatic(默认 false):仍是 classic __nekoJsxFactory + String out = compile("const el =
"); + assertTrue(out.contains("__nekoJsxFactory("), "默认仍是 classic runtime: " + out); + assertFalse(out.contains("import { jsx"), "默认不应注入 jsx import: " + out); + } + + @Test + void namespaceTagExecutesWithStringType() { + String out = compile("globalThis.result = "); + String runtime = "globalThis.__nekoJsxFactory = (type, props) => ({ type, props });\n"; + + try (CompilerExecutionAssertions.Evaluation evaluation = + CompilerExecutionAssertions.eval(runtime + out + "\nglobalThis.result")) { + assertEquals("svg:rect", evaluation.value().getMember("type").asString(), + "namespace JSX tag must reach the factory as a string: " + out); + } + } + + @Test + void automaticRuntimeSingleChildUsesPropsChildren() { + assertAutomaticTree( + "globalThis.result =
one
", + "{\"kind\":\"jsx\",\"type\":\"div\",\"children\":\"one\"}" + ); + } + + @Test + void automaticRuntimeMultipleChildrenUsesJsxsAndPropsChildrenArray() { + assertAutomaticTree( + "globalThis.result =
two
", + "{\"kind\":\"jsxs\",\"type\":\"div\",\"children\":[{\"kind\":\"jsx\",\"type\":\"span\",\"children\":null},\"two\"]}" + ); + } + + @Test + void automaticRuntimeFragmentUsesFragmentTypeAndPropsChildren() { + assertAutomaticTree( + "globalThis.result = <>two", + "{\"kind\":\"jsxs\",\"type\":\"Fragment\",\"children\":[{\"kind\":\"jsx\",\"type\":\"span\",\"children\":null},\"two\"]}" + ); + } + + private static void assertAutomaticTree(String source, String expectedJson) { + String out = NekoJsxCompiler.compileJsx(Path.of("test.jsx"), source, true).code(); + String runtime = """ + const Fragment = Symbol('Fragment'); + const node = (kind, type, props) => ({ + kind, + type: type === Fragment ? 'Fragment' : type, + children: props?.children ?? null + }); + const jsx = (type, props) => node('jsx', type, props); + const jsxs = (type, props) => node('jsxs', type, props); + """; + + try (CompilerExecutionAssertions.Evaluation evaluation = + CompilerExecutionAssertions.evalAutomatic(out + "\nJSON.stringify(globalThis.result)", runtime)) { + assertEquals(expectedJson, evaluation.value().asString(), "automatic runtime tree: " + out); + } + } +} diff --git a/common/src/test/java/com/tkisor/nekojs/core/compiler/NekoJsxFragmentTest.java b/common/src/test/java/com/tkisor/nekojs/core/compiler/NekoJsxFragmentTest.java new file mode 100644 index 00000000..fab5769e --- /dev/null +++ b/common/src/test/java/com/tkisor/nekojs/core/compiler/NekoJsxFragmentTest.java @@ -0,0 +1,52 @@ +package com.tkisor.nekojs.core.compiler; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class NekoJsxFragmentTest { + + @Test + void lowersEmptyAndNestedClassicFragments() { + String empty = NekoJsxCompiler.compileJsx(Path.of("empty.jsx"), + "globalThis.result = <>").code(); + String nested = NekoJsxCompiler.compileJsx(Path.of("nested.jsx"), + "globalThis.result = <><>inner").code(); + String runtime = "globalThis.__nekoJsxFactory = (type, props, ...children) => ({ type, children });\n" + + "globalThis.__nekoJsxFragment = (...children) => children;\n"; + + try (CompilerExecutionAssertions.Evaluation evaluation = + CompilerExecutionAssertions.eval(runtime + empty + "; JSON.stringify(globalThis.result)")) { + assertEquals("[]", evaluation.value().asString()); + } + try (CompilerExecutionAssertions.Evaluation evaluation = + CompilerExecutionAssertions.eval(runtime + nested + "; JSON.stringify(globalThis.result)")) { + assertEquals("[[\"inner\"],{\"type\":\"span\",\"children\":[]}]", evaluation.value().asString()); + } + } + + @Test + void lowersFragmentInsideExpressionChild() { + String out = NekoJsxCompiler.compileJsx(Path.of("expression.jsx"), + "globalThis.result =
{true && <>value}
").code(); + String runtime = "globalThis.__nekoJsxFactory = (type, props, ...children) => ({ type, children });\n" + + "globalThis.__nekoJsxFragment = (...children) => children;\n"; + + try (CompilerExecutionAssertions.Evaluation evaluation = + CompilerExecutionAssertions.eval(runtime + out + "; JSON.stringify(globalThis.result)")) { + assertEquals("{\"type\":\"div\",\"children\":[[\"value\"]]}", evaluation.value().asString()); + } + } + + @Test + void rejectsElementClosingTagForFragment() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> NekoJsxCompiler.compileJsx(Path.of("mismatch.jsx"), "const value = <>x
")); + + assertTrue(error.getMessage().contains("expected ''"), error.getMessage()); + } +} diff --git a/common/src/test/java/com/tkisor/nekojs/core/compiler/NekoTypeScriptCompilerTest.java b/common/src/test/java/com/tkisor/nekojs/core/compiler/NekoTypeScriptCompilerTest.java index 5c6e2e3a..7dcfb588 100644 --- a/common/src/test/java/com/tkisor/nekojs/core/compiler/NekoTypeScriptCompilerTest.java +++ b/common/src/test/java/com/tkisor/nekojs/core/compiler/NekoTypeScriptCompilerTest.java @@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -120,4 +121,274 @@ void namespaceInNestedScopeCompiles() { assertTrue(out.contains("var g"), "namespace 转换须前置 var 声明: " + out); assertFalse(out.contains("namespace g"), "namespace 关键字须被转换: " + out); } + + @Test + void erasesGenericArrowFunctionTypeParameters() { + // 泛型箭头 (x: T) => T:前导字符是 = 时 也须擦除,否则 GraalJS 拿到 报错 + String src = "const id = (x: T): T => x"; + String out = NekoTypeScriptCompiler.eraseTypescript(Path.of("test.ts"), src); + assertFalse(out.contains(""), "泛型箭头的 须擦除: " + out); + assertFalse(out.contains(": T"), "参数与返回类型注解须擦除: " + out); + assertTrue(out.contains("(x"), "参数名保留: " + out); + assertTrue(out.contains("=> x"), "箭头体保留: " + out); + } + + @Test + void erasesGenericArrowInCallArgument() { + // 泛型箭头作为函数实参:foo((x: T) => x) 也须正确擦除 + String src = "const r = wrap((y: U): U => y)"; + String out = NekoTypeScriptCompiler.eraseTypescript(Path.of("test.ts"), src); + assertFalse(out.contains(""), "泛型箭头 须擦除: " + out); + assertTrue(out.contains("(y"), "参数名保留: " + out); + } + + @Test + void doesNotMisinterpretComparisonAsGenericArgs() { + // 比较 a < b 返回布尔后调用 (c):不应把 < b > 当泛型擦除 + // 注意:这里 a < b > (c) 在运行时语义奇怪,但关键是 eraser 不应破坏它 + // 用更明确的非泛型场景:三元 + 数组索引 + String src = "const ok = arr[i] > 0 ? pos : neg"; + String out = NekoTypeScriptCompiler.eraseTypescript(Path.of("test.ts"), src); + assertTrue(out.contains("> 0"), "比较 > 0 须保留(不被当泛型右尖括号): " + out); + assertTrue(out.contains("? pos : neg"), "三元保留: " + out); + } + + @Test + void preservesInlineTypeImport() { + // TS 4.5+ 内联 type 修饰符:import { real, type T } from 'x' + // 只应擦除 type T,保留 real 与整个 import 语句 + String src = "import { real, type T } from 'mod'"; + String out = NekoTypeScriptCompiler.eraseTypescript(Path.of("test.ts"), src); + assertTrue(out.contains("real"), "内联 import 的运行时绑定 real 须保留: " + out); + assertTrue(out.contains("from"), "import ... from 须保留: " + out); + assertFalse(out.contains("type T"), "内联 type T 须擦除: " + out); + } + + @Test + void preservesMixedValueAndTypeInlineImport() { + // 多个值绑定 + 多个内联 type:import { a, b, type X, type Y } from 'm' + String src = "import { a, b, type X, type Y } from 'm'"; + String out = NekoTypeScriptCompiler.eraseTypescript(Path.of("test.ts"), src); + assertTrue(out.contains("a"), "值绑定 a 须保留: " + out); + assertTrue(out.contains("b"), "值绑定 b 须保留: " + out); + assertFalse(out.contains("type X"), "内联 type X 须擦除: " + out); + assertFalse(out.contains("type Y"), "内联 type Y 须擦除: " + out); + } + + @Test + void erasesWholeImportTypeStatement() { + // 纯 import type { T }(整条是 type import)仍整体删除 + String src = "import type { T } from 'mod'\nconst x = 1"; + String out = NekoTypeScriptCompiler.eraseTypescript(Path.of("test.ts"), src); + assertFalse(out.contains("import type"), "纯 import type 整条须擦除: " + out); + assertFalse(out.contains("from 'mod'"), "纯 import type 的 from 须擦除: " + out); + assertTrue(out.contains("const x = 1"), "后续语句保留: " + out); + } + + @Test + void enumNumericAutoIncrementFromExplicitValue() { + // enum E { A = 1, B } —— B 应自增为 2 + String src = "enum E { A = 1, B }"; + String out = NekoTypeScriptCompiler.eraseTypescript(Path.of("test.ts"), src); + // A=1, B=2 双向映射 + assertTrue(out.contains("[\"A\"] = 1"), "A=1 须保留: " + out); + assertTrue(out.contains("[\"B\"] = 2"), "B 应自增为 2: " + out); + } + + @Test + void enumComputedMemberPropagatesToNext() { + // enum E { A = base(), B } —— A 是计算值,B 应在运行时基于 A 自增(编译期不知 base() 的值) + // TS 语义:B = E["A"] + 1(运行时)。NekoJS 之前错误地把 B 重置成 0。 + String src = "enum E { A = base(), B }"; + String out = NekoTypeScriptCompiler.eraseTypescript(Path.of("test.ts"), src); + // A 用计算表达式赋值 + assertTrue(out.contains("[\"A\"] = base()"), "A 的计算表达式须保留: " + out); + // B 不应是固定 0,应引用 E["A"] + 1(运行时自增) + assertTrue(out.contains("E[\"A\"] + 1"), "B 应运行时基于 A 自增(E[\"A\"] + 1): " + out); + assertFalse(out.matches("(?s).*\\[\"B\"\\] = \\[E\\[\\[E\\[\"B\"\\].*"), "B 不应被当作已知数字双向映射: " + out); + } + + @Test + void namespaceExportInterfaceDoesNotLeaveStrayExport() { + // namespace 内 export interface —— interface 由 phase1 擦除,但 export 须被 namespace 转换剥除, + // 否则残留 export 在 IIFE 体内导致语法错 + String src = "namespace N { export interface I { x: number } export const v = 1 }"; + String out = NekoTypeScriptCompiler.eraseTypescript(Path.of("test.ts"), src); + // N.v 应被导出到 N.v = v(证明 const 成员被处理) + assertTrue(out.contains("N.v = v"), "const 成员 v 须被 namespace 导出: " + out); + // 不能残留裸 export(IIFE 体内 export 是非法语法) + // 检查 IIFE 体内没有 "export " 开头的裸语句(排除被擦成空格的) + assertFalse(out.matches("(?s)\\{[^}]*\\bexport\\s+(interface|type|const|function|let|var|class)"), + "namespace IIFE 体内不应残留 export 声明: " + out); + } + + @Test + void namespaceExportTypeAliasStripped() { + // namespace 内 export type X = ... —— type 由 phase1 擦除,export 须剥除 + String src = "namespace N { export type T = number; export function f(): void {} }"; + String out = NekoTypeScriptCompiler.eraseTypescript(Path.of("test.ts"), src); + assertTrue(out.contains("N.f = f"), "function 成员 f 须被 namespace 导出: " + out); + assertFalse(out.matches("(?s)\\(function \\(N\\)[^}]*\\bexport\\s+(type|interface)"), + "namespace IIFE 体内不应残留 export type/interface: " + out); + } + + @Test + void derivedConstructorParameterPropertyInitializesAfterSuper() { + String src = "class Base {} class Derived extends Base { constructor(public x: number) { super() } } new Derived(1).x"; + String out = NekoTypeScriptCompiler.eraseTypescript(Path.of("test.ts"), src); + + try (CompilerExecutionAssertions.Evaluation evaluation = CompilerExecutionAssertions.eval(out)) { + assertEquals(1, evaluation.value().asInt(), "derived parameter property must initialize after super(): " + out); + } + } + + @Test + void baseConstructorParameterPropertyInitializesAtBodyStart() { + String src = "class Base { constructor(public x: number) { this.seen = this.x } } new Base(2).seen"; + String out = NekoTypeScriptCompiler.eraseTypescript(Path.of("test.ts"), src); + + try (CompilerExecutionAssertions.Evaluation evaluation = CompilerExecutionAssertions.eval(out)) { + assertEquals(2, evaluation.value().asInt(), "base parameter property must initialize at constructor body start: " + out); + } + } + + @Test + void ordinaryTypedConstructorParameterDoesNotBecomeProperty() { + String src = "class C { constructor(x: number) {} } new C(1).x"; + String out = NekoTypeScriptCompiler.eraseTypescript(Path.of("ordinary-parameter.ts"), src); + + assertFalse(out.contains("this.x"), "ordinary constructor parameters must not produce property assignments: " + out); + try (CompilerExecutionAssertions.Evaluation evaluation = CompilerExecutionAssertions.eval(out)) { + assertTrue(evaluation.value().isNull(), "ordinary constructor parameter property access must evaluate undefined: " + out); + } + } + + @Test + void derivedParameterPropertyFollowsCompleteSuperCallWithNestedArguments() { + String src = "class Base { constructor(value) { this.value = value } } " + + "class Derived extends Base { constructor(public x: number) { super(({ value: (x + 1) }).value) } } " + + "const d = new Derived(2); d.value * 10 + d.x"; + String out = NekoTypeScriptCompiler.eraseTypescript(Path.of("test.ts"), src); + + try (CompilerExecutionAssertions.Evaluation evaluation = CompilerExecutionAssertions.eval(out)) { + assertEquals(32, evaluation.value().asInt(), "assignment must follow the complete nested super(...) call: " + out); + } + } + + @Test + void derivedParameterPropertyIgnoresPseudoSuperInCommentsAndStrings() { + String src = "class Base {} class Derived extends Base { constructor(public x: number) { " + + "/* super('comment') */ const text = 'super(\"string\")'; const template = `super(${x})`; super(); this.text = text + template " + + "} } new Derived(3).x"; + String out = NekoTypeScriptCompiler.eraseTypescript(Path.of("test.ts"), src); + + try (CompilerExecutionAssertions.Evaluation evaluation = CompilerExecutionAssertions.eval(out)) { + assertEquals(3, evaluation.value().asInt(), "pseudo-super text must not select the insertion point: " + out); + } + } + + @Test + void derivedParameterPropertyIgnoresPropertyAndOptionalPropertySuperCalls() { + String src = "class Base {} class Derived extends Base { constructor(public x: number) { " + + "const obj = { super() { return 0 } }; obj.super(); obj?.super(); super(); " + + "} } new Derived(4).x"; + String out = NekoTypeScriptCompiler.eraseTypescript(Path.of("property-super.ts"), src); + + try (CompilerExecutionAssertions.Evaluation evaluation = CompilerExecutionAssertions.eval(out)) { + assertEquals(4, evaluation.value().asInt(), + "obj.super()/obj?.super() must not select the insertion point: " + out); + } + } + + @Test + void derivedParameterPropertyIgnoresRegexSuperText() { + String src = "class Base {} class Derived extends Base { constructor(public x: number) { " + + "const pseudo = /super()/; const delimiters = /[})]/; super(); " + + "} } new Derived(5).x"; + String out = NekoTypeScriptCompiler.eraseTypescript(Path.of("regex-super.ts"), src); + + try (CompilerExecutionAssertions.Evaluation evaluation = CompilerExecutionAssertions.eval(out)) { + assertEquals(5, evaluation.value().asInt(), + "regex /super()/ text and delimiters must not select or disrupt super scanning: " + out); + } + } + + @Test + void derivedParameterPropertyFollowsStandaloneSuperStatementAcrossTrivia() { + String src = "class Base { constructor(value) { this.value = value } } " + + "class Derived extends Base { constructor(public x: number) { " + + "super(/* ) } */ /[})]/.test('x') ? x : x); // trailing comment\n" + + "this.after = this.x; } } const d = new Derived(6); d.value * 100 + d.after"; + String out = NekoTypeScriptCompiler.eraseTypescript(Path.of("trivia-super.ts"), src); + + try (CompilerExecutionAssertions.Evaluation evaluation = CompilerExecutionAssertions.eval(out)) { + assertEquals(606, evaluation.value().asInt(), + "assignment must be inserted after the complete standalone super(...); statement: " + out); + } + } + + @Test + void derivedParameterPropertyAcceptsAsiTerminatedSuperBeforeNextStatement() { + String src = "class Base {} class Derived extends Base { constructor(public x: number) { " + + "super() /* trailing trivia */\nthis.y = 1 } } const d = new Derived(7); d.x * 10 + d.y"; + String out = NekoTypeScriptCompiler.eraseTypescript(Path.of("asi-super.ts"), src); + + try (CompilerExecutionAssertions.Evaluation evaluation = CompilerExecutionAssertions.eval(out)) { + assertEquals(71, evaluation.value().asInt(), + "newline before a new statement must terminate standalone super(): " + out); + } + } + + @Test + void derivedParameterPropertyFailsClosedForNonStandaloneSuperExpressions() { + String[] sources = { + "class Base {} class Derived extends Base { constructor(public x: number) { super().foo(); } }", + "class Base {} class Derived extends Base { constructor(public x: number) { super()\n.foo(); } }", + "class Base {} class Derived extends Base { constructor(public x: number) { super()\n?.foo(); } }", + "class Base {} class Derived extends Base { constructor(public x: number) { super()\n[0]; } }", + "class Base {} class Derived extends Base { constructor(public x: number) { const y = super(); } }" + }; + + for (String src : sources) { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> NekoTypeScriptCompiler.eraseTypescript(Path.of("non-standalone-super.ts"), src)); + assertTrue(ex.getMessage().contains("top-level super"), + "non-standalone super use must fail closed: " + ex.getMessage()); + } + } + + @Test + void derivedParameterPropertyWithoutTopLevelSuperFailsClosed() { + String src = "class Base {} class Derived extends Base { constructor(public x: number) { " + + "const text = 'super()'; const template = `super()`; /* super() */ " + + "function nested() { super() } class Inner extends Base { constructor() { super() } } " + + "} }"; + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> NekoTypeScriptCompiler.eraseTypescript(Path.of("missing-super.ts"), src)); + assertTrue(ex.getMessage().contains("top-level super"), + "错误信息须明确说明缺少顶层 super 调用: " + ex.getMessage()); + assertTrue(ex.getMessage().contains("missing-super.ts"), + "错误信息须包含文件路径: " + ex.getMessage()); + } + + @Test + void classDecoratorThrowsUnsupported() { + // @Component 装饰器:NekoJS 是脚本引擎非 TS 框架,不支持装饰器,须清晰报错而非产出坏 JS + String src = "@Component\nclass Foo {}"; + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> NekoTypeScriptCompiler.eraseTypescript(Path.of("test.ts"), src)); + assertTrue(ex.getMessage().toLowerCase().contains("decorator"), + "错误信息须提及 decorator: " + ex.getMessage()); + } + + @Test + void methodDecoratorThrowsUnsupported() { + // 类成员装饰器同样须报错 + String src = "class Foo {\n @Log\n greet() {}\n}"; + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> NekoTypeScriptCompiler.eraseTypescript(Path.of("test.ts"), src)); + assertTrue(ex.getMessage().toLowerCase().contains("decorator"), + "错误信息须提及 decorator: " + ex.getMessage()); + } } diff --git a/common/src/test/java/com/tkisor/nekojs/core/compiler/NodeModuleTypeDocsTest.java b/common/src/test/java/com/tkisor/nekojs/core/compiler/NodeModuleTypeDocsTest.java new file mode 100644 index 00000000..41fc1f82 --- /dev/null +++ b/common/src/test/java/com/tkisor/nekojs/core/compiler/NodeModuleTypeDocsTest.java @@ -0,0 +1,309 @@ +package com.tkisor.nekojs.core.compiler; + +import com.tkisor.nekojs.api.catalog.ManualDeclarationCatalogEntry; +import com.tkisor.nekojs.api.catalog.TypeDocCatalogEntry; +import com.tkisor.nekojs.core.plugin.TypeDocsRegister; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link NodeModuleTypeDocs} 测试:{@code extractTS}(.ts 类型注解) + * 以及 {@code registerBuiltin} 的端到端提取链路。 + * + *

成员签名以 {@code export} 暴露({@code declare module} 内非 export 声明对 require() 不可见)。 + */ +class NodeModuleTypeDocsTest { + + // ============ extractTS ============ + + @Test + void extractsTypedNodeModule() { + String src = """ +;(function () { + const runtime = globalThis.__nekoNodeInternal.runtime + + interface ParsedPath { root: string; dir: string; base: string; ext: string; name: string } + type Platform = 'posix' | 'win32'; + + function normalize(p: string): string { return runtime.normalize(p) } + function join(...parts: string[]): string { return runtime.join(parts) } + function id(x: T): T { return x } + const sep: string = runtime.sep + const delimiter: string = runtime.delimiter + + const api = { + normalize, + join, + id, + sep, + delimiter, + parse(path: string): ParsedPath { return runtime.parse(path) }, + version: '1.0.0' + } + + globalThis.__nekoNodeDefine(['path', 'node:path'], api) +})() +"""; + String out = NodeModuleTypeDocs.extractTS(src); + assertFalse(out.isBlank(), out); + assertTrue(out.contains("declare module 'path' {"), out); + assertTrue(out.contains("declare module 'node:path' {"), out); + assertTrue(out.contains("export function normalize(p: string): string;"), out); + assertTrue(out.contains("export function join(...parts: string[]): string;"), out); + assertTrue(out.contains("export function id(x: T): T;"), out); + assertTrue(out.contains("export const sep: string;"), out); + assertTrue(out.contains("export const delimiter: string;"), out); + assertTrue(out.contains("export function parse(path: string): ParsedPath;"), out); + assertTrue(out.contains("export const version: string;"), out); + assertTrue(out.contains("interface ParsedPath { root: string; dir: string; base: string; ext: string; name: string }"), out); + assertTrue(out.contains("type Platform = 'posix' | 'win32';"), out); + } + + @Test + void extractsDirectObjectLiteralExports() { + String src = """ +;(function () { + const runtime = globalThis.__nekoNodeInternal.runtime + function arch(): string { return runtime.arch() } + globalThis.__nekoNodeDefine(['os', 'node:os'], { arch, version: '1.2.3' }) +})() +"""; + String out = NodeModuleTypeDocs.extractTS(src); + assertTrue(out.contains("declare module 'os' {"), out); + assertTrue(out.contains("declare module 'node:os' {"), out); + assertTrue(out.contains("export function arch(): string;"), out); + assertTrue(out.contains("export const version: string;"), out); + } + + @Test + void handlesGenericAndArrayReturnTypes() { + String src = """ +;(function () { + function cpus(): CpuInfo[] { return [] } + const api = { cpus } + globalThis.__nekoNodeDefine(['os'], api) +})() +"""; + String out = NodeModuleTypeDocs.extractTS(src); + assertTrue(out.contains("export function cpus(): CpuInfo[];"), out); + } + + @Test + void arrowFunctionExportBecomesFunctionSig() { + String src = """ +;(function () { + const api = { run: (n: number): boolean => n > 0 } + globalThis.__nekoNodeDefine(['m'], api) +})() +"""; + String out = NodeModuleTypeDocs.extractTS(src); + assertTrue(out.contains("export function run(n: number): boolean;"), out); + } + + @Test + void tsSingleStringIdIsAccepted() { + String src = """ +;(function () { + const api = { a: 1 } + globalThis.__nekoNodeDefine('solo', api) +})() +"""; + String out = NodeModuleTypeDocs.extractTS(src); + assertTrue(out.contains("declare module 'solo' {"), out); + assertTrue(out.contains("export const a: number;"), out); + } + + // ============ registerBuiltin 端到端 ============ + + @Test + void registerBuiltinAllNodeModulesAutoExtracted() { + List entries = new ArrayList<>(); + TypeDocsRegister registry = new TypeDocsRegister() { + @Override + public void register(TypeDocCatalogEntry entry) {} + @Override + public void registerManualDeclaration(ManualDeclarationCatalogEntry entry) { + entries.add(entry); + } + }; + NodeModuleTypeDocs.registerBuiltin(registry); + String all = entries.stream() + .map(ManualDeclarationCatalogEntry::declaration) + .reduce("", String::concat); + // 自动提取的 example.ts 模块声明 + assertTrue(all.contains("declare module 'node:example' {"), all); + assertTrue(all.contains("declare module 'example' {"), all); + assertTrue(all.contains("export function getInfo(id: number): ExampleInfo;"), all); + assertTrue(all.contains("export function status(): Status;"), all); + assertTrue(all.contains("export const version: string;"), all); + assertTrue(all.contains("interface ExampleInfo { id: number; name: string }"), all); + assertTrue(all.contains("type Status = 'ok' | 'error';"), all); + // 所有内置 node 模块均由 .ts 自动提取(无手写回退);node:path 关键签名在 + assertTrue(all.contains("declare module 'node:path'"), all); + assertTrue(all.contains("join(...parts: string[]): string"), all); + } + + // ============ 通用降级 ============ + + @Test + void tsReturnsEmptyWhenNoDefineCall() { + assertEquals("", NodeModuleTypeDocs.extractTS("const x = 1")); + } + + @Test + void returnsEmptyForBlankInput() { + assertEquals("", NodeModuleTypeDocs.extractTS("")); + assertEquals("", NodeModuleTypeDocs.extractTS(null)); + } + + @Test + void ignoresDefineAssignment() { + String src = "globalThis.__nekoNodeDefine = function (names, value) {}"; + assertEquals("", NodeModuleTypeDocs.extractTS(src)); + } + + @Test + void exampleTsIsErasedToRunnableJs() throws Exception { + // example.ts 在运行时由 loadManifest 擦除后 eval;确认擦除不抛异常且类型被干净移除 + String src = readResource("nekojs/node/modules/example.ts"); + String js = NekoTypeScriptCompiler.eraseTypescript(Path.of("example.ts"), src); + assertFalse(js.contains("interface ExampleInfo"), "interface must be erased:\n" + js); + assertFalse(js.contains("type Status"), "type alias must be erased:\n" + js); + assertFalse(js.contains(": number"), "param type annotation must be erased:\n" + js); + assertFalse(js.contains(": string"), "const type annotation must be erased:\n" + js); + assertTrue(js.contains("__nekoNodeDefine(['example', 'node:example']"), "runtime define must remain:\n" + js); + } + + private static String readResource(String path) throws IOException { + try (InputStream in = NodeModuleTypeDocsTest.class.getClassLoader().getResourceAsStream(path)) { + assertNotNull(in, "resource not found: " + path); + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + + // ============ extractTS 增强:class / 嵌套对象 / 多 define / getter-setter ============ + + @Test + void extractsClassMembers() { + String src = """ +;(function () { + class EventEmitter { + private _events: Record = {}; + static defaultMaxListeners: number = 10; + constructor() {} + on(name: string, fn: () => void): this { return this } + static listenerCount(e: EventEmitter, name: string): number { return 0 } + get maxListeners(): number { return 10 } + set maxListeners(v: number) {} + } + const api = { EventEmitter } + globalThis.__nekoNodeDefine(['events', 'node:events'], api) +})() +"""; + String out = NodeModuleTypeDocs.extractTS(src); + assertTrue(out.contains("declare module 'node:events' {"), out); + assertTrue(out.contains("export declare class EventEmitter"), out); + assertTrue(out.contains("constructor();"), out); + assertTrue(out.contains("on(name: string, fn: () => void): this;"), out); + assertTrue(out.contains("static defaultMaxListeners: number;"), out); + assertTrue(out.contains("static listenerCount(e: EventEmitter, name: string): number;"), out); + assertTrue(out.contains("maxListeners: number;"), out); + } + + @Test + void extractsNestedObjectInlineType() { + String src = """ +;(function () { + const util = { + types: { + isPromise: (value: unknown): boolean => !!value, + isMap: (value: unknown): value is Map => value instanceof Map + }, + version: '1.0' + } + globalThis.__nekoNodeDefine(['util', 'node:util'], util) +})() +"""; + String out = NodeModuleTypeDocs.extractTS(src); + assertTrue(out.contains("export const types:"), out); + assertTrue(out.contains("isPromise(value: unknown): boolean"), out); + assertTrue(out.contains("isMap(value: unknown): value is Map"), out); + assertTrue(out.contains("export const version: string;"), out); + } + + @Test + void handlesMultipleDefineCalls() { + String src = """ +;(function () { + const fs = { readFileSync: (p: string): Buffer => null } + globalThis.__nekoNodeDefine(['fs', 'node:fs'], fs) + const promises = { readFile: (p: string): Promise => null } + globalThis.__nekoNodeDefine(['fs/promises', 'node:fs/promises'], promises) +})() +"""; + String out = NodeModuleTypeDocs.extractTS(src); + assertTrue(out.contains("declare module 'node:fs' {"), out); + assertTrue(out.contains("export function readFileSync(p: string): Buffer;"), out); + assertTrue(out.contains("declare module 'node:fs/promises' {"), out); + assertTrue(out.contains("export function readFile(p: string): Promise;"), out); + } + + @Test + void topLevelTypeAliasWithArrowMemberNotTruncated() { + // type X = { m?: (a: T) => R; ... }:=> 的 > 曾被 typeDeclEnd 误判为泛型闭合(与 ()[]{} 共用 depth), + // 在首个 ; 处提前截断,丢失闭合 } 与后续成员 → 外层 declare module 借用闭合 },TS 报 "Expected '}'"。 + String src = """ +;(function () { + type NekoTestExtra = { mapStackLine?: (line: string) => string; formatError?: (error: unknown) => string } + const api = {} + globalThis.__nekoNodeDefine(['test', 'node:test'], api) +})() +"""; + String out = NodeModuleTypeDocs.extractTS(src); + assertTrue(out.contains( + "type NekoTestExtra = { mapStackLine?: (line: string) => string; formatError?: (error: unknown) => string }"), + "含 => 成员的顶层 type 别名须完整提取(含闭合 }): " + out); + assertTrue(out.contains("declare module 'node:test' {"), out); + } + + @Test + void nodeModuleJSDocCommentsExtracted() throws Exception { + // .ts node 模块顶层声明前的 /** */ JSDoc 随导出签名一并提取,使补全可见文档(实现与声明同源) + String src = readResource("nekojs/node/modules/example.ts"); + String out = NodeModuleTypeDocs.extractTS(src); + assertTrue(out.contains("/** 根据 id 构造示例信息。 */"), out); + assertTrue(out.contains("/** 返回当前状态。 */"), out); + // 注释紧贴其后的 export function 签名(经 indentBlock 各缩进 4 空格) + assertTrue(out.contains("/** 根据 id 构造示例信息。 */\n export function getInfo(id: number): ExampleInfo;"), out); + assertTrue(out.contains("/** 返回当前状态。 */\n export function status(): Status;"), out); + } + + @Test + void extractsObjectLiteralGetters() { + String src = """ +;(function () { + const proc = { + get platform(): string { return 'linux' }, + get pid(): number { return 1 }, + set exitCode(code: number) {} + } + globalThis.__nekoNodeDefine(['process', 'node:process'], proc) +})() +"""; + String out = NodeModuleTypeDocs.extractTS(src); + assertTrue(out.contains("export const platform: string;"), out); + assertTrue(out.contains("export const pid: number;"), out); + assertTrue(out.contains("export const exitCode: number;"), out); + } +} diff --git a/common/src/test/java/com/tkisor/nekojs/core/fs/JSConfigModelTest.java b/common/src/test/java/com/tkisor/nekojs/core/fs/JSConfigModelTest.java new file mode 100644 index 00000000..614a24f8 --- /dev/null +++ b/common/src/test/java/com/tkisor/nekojs/core/fs/JSConfigModelTest.java @@ -0,0 +1,41 @@ +package com.tkisor.nekojs.core.fs; + +import com.google.gson.Gson; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; + +class JSConfigModelTest { + private static final Gson GSON = new Gson(); + + @Test + void defaultsToClassicJsxConfiguration() { + JSConfigModel.CompilerOptions options = new JSConfigModel().compilerOptions; + + assertEquals("react", options.jsx); + assertEquals("__nekoJsxFactory", options.jsxFactory); + assertEquals("__nekoJsxFragment", options.jsxFragmentFactory); + assertNull(options.jsxImportSource); + assertFalse(options.experimentalDecorators); + } + + @Test + void automaticJsxRuntimeUsesTypeScriptAutomaticRuntimeSettings() { + JSConfigModel model = new JSConfigModel(); + + model.useAutomaticJsxRuntime(); + + JSConfigModel.CompilerOptions options = model.compilerOptions; + assertEquals("react-jsx", options.jsx); + assertEquals("nekojs", options.jsxImportSource); + assertNull(options.jsxFactory); + assertNull(options.jsxFragmentFactory); + assertFalse(options.experimentalDecorators); + + String json = GSON.toJson(model); + assertFalse(json.contains("jsxFactory")); + assertFalse(json.contains("jsxFragmentFactory")); + } +} diff --git a/common/src/test/java/com/tkisor/nekojs/core/module/NekoModuleResolverTest.java b/common/src/test/java/com/tkisor/nekojs/core/module/NekoModuleResolverTest.java new file mode 100644 index 00000000..afe28fa0 --- /dev/null +++ b/common/src/test/java/com/tkisor/nekojs/core/module/NekoModuleResolverTest.java @@ -0,0 +1,92 @@ +package com.tkisor.nekojs.core.module; + +import com.tkisor.nekojs.core.ScriptFilePolicy; +import com.tkisor.nekojs.core.compiler.ScriptCompilerRegistry; +import com.tkisor.nekojs.core.fs.NekoJSPaths; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class NekoModuleResolverTest { + + @TempDir + Path gameDir; + + @Test + void resolvesBareUserNodeModuleAsScript() throws Exception { + NekoModuleResolver resolver = resolverFor(gameDir); + Path runtime = gameDir.resolve("nekojs/node_modules/nekojs/jsx-runtime.js"); + Files.createDirectories(runtime.getParent()); + Files.writeString(runtime, "export const jsx = () => null;"); + + NekoResolvedModule resolved = resolver.resolve("nekojs/server_scripts/main.js", "nekojs/jsx-runtime"); + + assertEquals(NekoModuleKind.SCRIPT, resolved.kind()); + assertEquals(runtime.toRealPath(), resolved.path()); + } + + @Test + void rejectsBareModuleTraversalOutsideNodeModules() throws Exception { + NekoModuleResolver resolver = resolverFor(gameDir); + Path secret = gameDir.resolve("nekojs/server_scripts/secret.js"); + Files.createDirectories(secret.getParent()); + Files.writeString(secret, "export const secret = true;"); + + IOException error = assertThrows(IOException.class, + () -> resolver.resolve("nekojs/server_scripts/main.js", "package/../../server_scripts/secret")); + + assertTrue(error.getMessage().contains("node_modules"), error::getMessage); + } + + @Test + void resolvesScopedBareModuleSubpath() throws Exception { + NekoModuleResolver resolver = resolverFor(gameDir); + Path module = gameDir.resolve("nekojs/node_modules/@scope/pkg/subpath.js"); + Files.createDirectories(module.getParent()); + Files.writeString(module, "export const value = true;"); + + NekoResolvedModule resolved = resolver.resolve("nekojs/server_scripts/main.js", "@scope/pkg/subpath"); + + assertEquals(NekoModuleKind.SCRIPT, resolved.kind()); + assertEquals(module.toRealPath(), resolved.path()); + } + + @Test + void preservesBuiltinJavaAndUnknownBareSpecifierClassification() throws Exception { + NekoModuleResolver resolver = resolverFor(gameDir); + + assertEquals(NekoModuleKind.BUILTIN, resolver.resolve("nekojs/server_scripts/main.js", "fs").kind()); + assertEquals(NekoModuleKind.BUILTIN, resolver.resolve("nekojs/server_scripts/main.js", "node:fs").kind()); + assertEquals(NekoModuleKind.JAVA_MODULE, resolver.resolve("nekojs/server_scripts/main.js", "java:example/Widget").kind()); + assertEquals(NekoModuleKind.SPECIAL, resolver.resolve("nekojs/server_scripts/main.js", "unknown-package").kind()); + } + + @Test + void propagatesUnsupportedBareNodeModuleCandidateErrors() throws Exception { + NekoModuleResolver resolver = resolverFor(gameDir); + Path unsupported = gameDir.resolve("nekojs/node_modules/nekojs/jsx-runtime.txt"); + Files.createDirectories(unsupported.getParent()); + Files.writeString(unsupported, "not a script"); + + IOException error = assertThrows(IOException.class, + () -> resolver.resolve("nekojs/server_scripts/main.js", "nekojs/jsx-runtime.txt")); + + assertTrue(error.getMessage().startsWith("Unsupported module file type:"), error::getMessage); + } + + private static NekoModuleResolver resolverFor(Path gameDir) throws Exception { + Constructor constructor = NekoJSPaths.class.getDeclaredConstructor(Path.class); + constructor.setAccessible(true); + NekoJSPaths paths = constructor.newInstance(gameDir); + ScriptCompilerRegistry compilers = ScriptCompilerRegistry.createRuntimeRegistry(); + return new NekoModuleResolver(paths, new ScriptFilePolicy(compilers), compilers); + } +} diff --git a/common/src/test/java/com/tkisor/nekojs/core/plugin/BindingSideSelectionTest.java b/common/src/test/java/com/tkisor/nekojs/core/plugin/BindingSideSelectionTest.java new file mode 100644 index 00000000..19230f06 --- /dev/null +++ b/common/src/test/java/com/tkisor/nekojs/core/plugin/BindingSideSelectionTest.java @@ -0,0 +1,45 @@ +package com.tkisor.nekojs.core.plugin; + +import com.tkisor.nekojs.api.ScriptType; +import com.tkisor.nekojs.api.ScriptTypePredicate; +import com.tkisor.nekojs.testfixture.TestPlatformInit; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BindingSideSelectionTest { + + @BeforeAll + static void initPlatform() { + TestPlatformInit.ensureInitialized(); + } + + @Test + void clientProcessRegistersClientBindings() { + ScriptTypePredicate predicate = NekoPluginBootstrap.bindingPredicate(true); + Set matched = predicate.streamMatched().collect(Collectors.toSet()); + assertTrue(matched.contains(ScriptType.CLIENT), + "client process must register CLIENT bindings"); + assertTrue(matched.contains(ScriptType.STARTUP), + "client process must register STARTUP bindings"); + assertTrue(matched.contains(ScriptType.SERVER), + "client process must register SERVER bindings (integrated server)"); + } + + @Test + void dedicatedServerExcludesClientBindings() { + ScriptTypePredicate predicate = NekoPluginBootstrap.bindingPredicate(false); + Set matched = predicate.streamMatched().collect(Collectors.toSet()); + assertFalse(matched.contains(ScriptType.CLIENT), + "dedicated server must NOT register CLIENT bindings"); + assertTrue(matched.contains(ScriptType.SERVER), + "dedicated server must register SERVER bindings"); + assertTrue(matched.contains(ScriptType.STARTUP), + "dedicated server must register STARTUP bindings"); + } +} diff --git a/common/src/test/java/com/tkisor/nekojs/core/plugin/DeepFreezeTest.java b/common/src/test/java/com/tkisor/nekojs/core/plugin/DeepFreezeTest.java new file mode 100644 index 00000000..b1061246 --- /dev/null +++ b/common/src/test/java/com/tkisor/nekojs/core/plugin/DeepFreezeTest.java @@ -0,0 +1,59 @@ +package com.tkisor.nekojs.core.plugin; + +import com.tkisor.nekojs.api.ScriptType; +import com.tkisor.nekojs.api.data.BindingRegistry; +import com.tkisor.nekojs.api.event.EventGroup; +import com.tkisor.nekojs.script.ScriptTypedValue; +import com.tkisor.nekojs.testfixture.TestPlatformInit; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class DeepFreezeTest { + + @BeforeAll + static void initPlatform() { + TestPlatformInit.ensureInitialized(); + } + + @Test + void frozenBindingSnapshotIsDetachedFromSource() { + var typed = ScriptTypedValue.of(BindingRegistry.BindingRegistryImpl::new); + var serverReg = typed.at(ScriptType.SERVER); + serverReg.register("before_freeze", "v1"); + + Map> snapshot = + NekoPluginBootstrap.freezeBindings(typed); + + serverReg.register("after_freeze", "v2"); + + assertTrue(snapshot.get(ScriptType.SERVER).containsKey("before_freeze")); + assertFalse(snapshot.get(ScriptType.SERVER).containsKey("after_freeze"), + "frozen snapshot must not reflect post-freeze registrations"); + } + + @Test + void frozenBindingSnapshotIsImmutable() { + var typed = ScriptTypedValue.of(BindingRegistry.BindingRegistryImpl::new); + typed.at(ScriptType.CLIENT).register("x", 1); + + Map> snapshot = + NekoPluginBootstrap.freezeBindings(typed); + + assertThrows(UnsupportedOperationException.class, + () -> snapshot.get(ScriptType.CLIENT).put("y", null)); + } + + @Test + void frozenEventGroupRejectsNewBuses() { + var group = EventGroup.of("test_group"); + group.freeze(); + + assertThrows(IllegalStateException.class, + () -> group.add("bus2", ScriptType.SERVER, null), + "frozen EventGroup must reject new bus registration"); + } +} diff --git a/common/src/test/java/com/tkisor/nekojs/platform/PlatformCapabilityTest.java b/common/src/test/java/com/tkisor/nekojs/platform/PlatformCapabilityTest.java new file mode 100644 index 00000000..d044e2b7 --- /dev/null +++ b/common/src/test/java/com/tkisor/nekojs/platform/PlatformCapabilityTest.java @@ -0,0 +1,26 @@ +package com.tkisor.nekojs.platform; + +import org.junit.jupiter.api.Test; + +import java.util.EnumSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class PlatformCapabilityTest { + + @Test + void capabilitySetIsImmutable() { + Set caps = Set.of(PlatformCapability.TAGS, PlatformCapability.NETWORK_CUSTOM_CHANNEL); + assertThrows(UnsupportedOperationException.class, () -> caps.add(PlatformCapability.DATA_GENERATION)); + } + + @Test + void enumValuesAreStable() { + EnumSet all = EnumSet.allOf(PlatformCapability.class); + assertTrue(all.contains(PlatformCapability.TAGS)); + assertTrue(all.contains(PlatformCapability.RECIPE_HOT_RELOAD)); + assertTrue(all.contains(PlatformCapability.NETWORK_CUSTOM_CHANNEL)); + assertTrue(all.size() >= 20, "expected at least 20 capabilities, got " + all.size()); + } +} diff --git a/common/src/test/java/com/tkisor/nekojs/probe/ClassDeclGeneratorTest.java b/common/src/test/java/com/tkisor/nekojs/probe/ClassDeclGeneratorTest.java new file mode 100644 index 00000000..ade197a2 --- /dev/null +++ b/common/src/test/java/com/tkisor/nekojs/probe/ClassDeclGeneratorTest.java @@ -0,0 +1,49 @@ +package com.tkisor.nekojs.probe; + +import com.tkisor.nekojs.probe.types.TypeAliasRegistry; +import com.tkisor.nekojs.probe.types.TypeConverter; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link ClassDeclGenerator} 测试:Java 的 getXxx()/isXxx() getter 在 TS 声明里同时以 + * 属性({@code get prop(): T})与方法({@code getXxx(): T})两种形式暴露, + * 与 GraalJS host object 运行时(如 {@code event.getEntity()})保持一致—— + * 否则 TS getter 仅允许 {@code .xxx} 属性访问,调用 {@code getXxx()} 会补全缺失并报错。 + */ +class ClassDeclGeneratorTest { + + /** 样例类:含 getXxx()/isXxx() getter 与普通实例方法。 */ + public static class Sample { + public String getName() { return "x"; } + public boolean isActive() { return false; } + public void doWork() {} + } + + @Test + void getterEmittedAsBothPropertyAndMethod() { + ClassDeclGenerator gen = new ClassDeclGenerator(new TypeConverter(new TypeAliasRegistry())); + String decl = gen.generate(Sample.class); + + // TS getter(属性访问 .name / .active) + assertTrue(decl.contains("get name():"), decl); + assertTrue(decl.contains("get active():"), decl); + // 同名方法形式(getEntity() 式调用,与 GraalJS 运行时一致;此前缺失导致调用报错) + assertTrue(decl.contains("getName():"), decl); + assertTrue(decl.contains("isActive():"), decl); + // getter 与方法形式返回类型必须一致(同一 TypeConverter 输出) + assertEquals(typeAfter(decl, "get name():"), typeAfter(decl, "getName():")); + assertEquals(typeAfter(decl, "get active():"), typeAfter(decl, "isActive():")); + // 普通实例方法不受影响 + assertTrue(decl.contains("doWork():"), decl); + } + + /** 取 decl 中 prefix 之后的类型文本(到分号为止),用于比较 getter/方法形式类型是否一致。 */ + private static String typeAfter(String decl, String prefix) { + int i = decl.indexOf(prefix); + String rest = decl.substring(i + prefix.length()); + return rest.substring(0, rest.indexOf(';')).trim(); + } +} diff --git a/common/src/test/java/com/tkisor/nekojs/probe/ProbeRegistryTest.java b/common/src/test/java/com/tkisor/nekojs/probe/ProbeRegistryTest.java new file mode 100644 index 00000000..2d215862 --- /dev/null +++ b/common/src/test/java/com/tkisor/nekojs/probe/ProbeRegistryTest.java @@ -0,0 +1,88 @@ +package com.tkisor.nekojs.probe; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ProbeRegistryTest { + @Test + void fallbackIsUsedWithoutThirdPartyProvider() throws Exception { + resetRegistry(); + ProbeGenerator fallback = generator("fallback"); + + ProbeRegistry.setFallback(fallback, "builtin"); + ProbeRegistry.lock(); + + assertSame(fallback, ProbeRegistry.getGenerator()); + } + + @Test + void thirdPartyProviderReplacesFallback() throws Exception { + resetRegistry(); + ProbeGenerator fallback = generator("fallback"); + ProbeGenerator replacement = generator("replacement"); + + ProbeRegistry.setFallback(fallback, "builtin"); + ProbeRegistry.setGenerator(replacement, "plugin"); + ProbeRegistry.lock(); + + assertSame(replacement, ProbeRegistry.getGenerator()); + assertEquals(List.of("replacement (plugin)"), ProbeRegistry.getRegistrars()); + } + + @Test + void multipleThirdPartyProvidersConflict() throws Exception { + resetRegistry(); + ProbeRegistry.setFallback(generator("fallback"), "builtin"); + ProbeRegistry.setGenerator(generator("first"), "plugin-a"); + ProbeRegistry.setGenerator(generator("second"), "plugin-b"); + + assertThrows(IllegalStateException.class, ProbeRegistry::lock); + } + + @Test + void registrationAfterLockFails() throws Exception { + resetRegistry(); + ProbeRegistry.setFallback(generator("fallback"), "builtin"); + ProbeRegistry.lock(); + + assertThrows(IllegalStateException.class, + () -> ProbeRegistry.setGenerator(generator("late"), "plugin")); + } + + private static ProbeGenerator generator(String name) { + return new ProbeGenerator() { + @Override + public String name() { + return name; + } + + @Override + public GenerateResult generate(com.tkisor.nekojs.api.catalog.NekoScriptCatalogSnapshot snapshot, + java.nio.file.Path outputDir) { + return GenerateResult.success(0, 0L); + } + }; + } + + private static void resetRegistry() throws Exception { + setField("generator", null); + setField("fallbackGenerator", null); + setField("fallbackRegistrar", null); + setField("locked", false); + Field registrars = ProbeRegistry.class.getDeclaredField("registrars"); + registrars.setAccessible(true); + ((List) registrars.get(null)).clear(); + } + + private static void setField(String name, Object value) throws Exception { + Field field = ProbeRegistry.class.getDeclaredField(name); + field.setAccessible(true); + field.set(null, value); + } +} diff --git a/common/src/test/java/com/tkisor/nekojs/testfixture/TestPlatformInit.java b/common/src/test/java/com/tkisor/nekojs/testfixture/TestPlatformInit.java new file mode 100644 index 00000000..36d275f3 --- /dev/null +++ b/common/src/test/java/com/tkisor/nekojs/testfixture/TestPlatformInit.java @@ -0,0 +1,82 @@ +package com.tkisor.nekojs.testfixture; + +import com.tkisor.nekojs.platform.IModInfo; +import com.tkisor.nekojs.platform.IPlatform; +import com.tkisor.nekojs.platform.Platform; +import com.tkisor.nekojs.platform.PlatformCapability; + +import java.lang.reflect.Field; +import java.nio.file.Path; +import java.util.Map; + +public final class TestPlatformInit { + + private TestPlatformInit() { + } + + private static volatile boolean initialized = false; + + public static synchronized void ensureInitialized() { + if (initialized) { + return; + } + ensureInitialized(Path.of(System.getProperty("java.io.tmpdir"), "nekojs-test-gamedir")); + initialized = true; + } + + public static void ensureInitialized(Path gameDir) { + gameDir.toFile().mkdirs(); + try { + Field instance = Platform.class.getDeclaredField("INSTANCE"); + instance.setAccessible(true); + if (instance.get(null) == null) { + Platform.init(new TestIPlatform(gameDir)); + } + } catch (Exception e) { + throw new RuntimeException("Failed to initialize Platform for tests", e); + } + } + + public static final class TestIPlatform implements IPlatform { + private final Path gameDir; + + public TestIPlatform(Path gameDir) { + this.gameDir = gameDir; + } + + @Override + public boolean isClient() { + return false; + } + + @Override + public boolean isDevelopment() { + return true; + } + + @Override + public String getMcVersion() { + return "0.0.0"; + } + + @Override + public Path getGameDir() { + return gameDir; + } + + @Override + public Map getMods() { + return Map.of(); + } + + @Override + public IModInfo getInfo(String modID) { + return null; + } + + @Override + public java.util.Set capabilities() { + return java.util.Set.of(); + } + } +}