From 070b79658c1fc836c18c224c8e944152dafb8382 Mon Sep 17 00:00:00 2001 From: kejun Date: Mon, 3 Nov 2025 23:52:45 +0800 Subject: [PATCH 1/2] feat: implement include directive with support for file inclusion and circular reference detection --- src/core/directives/include.ts | 92 +++++++++++++++++++++++ src/core/directives/index.ts | 1 + src/core/types/node.ts | 6 ++ src/core/types/token.ts | 5 ++ src/core/unplugin.ts | 4 +- test/__snapshots__/include.test.ts.snap | 55 ++++++++++++++ test/fixtures/include-base.txt | 2 + test/fixtures/include-circular-a.txt | 3 + test/fixtures/include-circular-b.txt | 3 + test/fixtures/include-main.txt | 12 +++ test/fixtures/include-with-directives.txt | 6 ++ test/include.test.ts | 58 ++++++++++++++ 12 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 src/core/directives/include.ts create mode 100644 test/__snapshots__/include.test.ts.snap create mode 100644 test/fixtures/include-base.txt create mode 100644 test/fixtures/include-circular-a.txt create mode 100644 test/fixtures/include-circular-b.txt create mode 100644 test/fixtures/include-main.txt create mode 100644 test/fixtures/include-with-directives.txt create mode 100644 test/include.test.ts diff --git a/src/core/directives/include.ts b/src/core/directives/include.ts new file mode 100644 index 0000000..56f23e1 --- /dev/null +++ b/src/core/directives/include.ts @@ -0,0 +1,92 @@ +import type { IncludeStatement, IncludeToken } from '../types' +import { existsSync, readFileSync } from 'node:fs' +import { isAbsolute, resolve } from 'node:path' +import { defineDirective } from '../directive' +import { createProgramNode, simpleMatchToken } from '../utils' + +export const includeDirective = defineDirective((context) => { + // 用于跟踪已包含的文件,防止循环引用(每次转换时重置) + const includedFilesStack: Set[] = [] + + return { + lex(comment) { + return simpleMatchToken(comment, /#(include)\s+["<](.+)[">]/) + }, + parse(token) { + if (token.type === 'include') { + this.current++ + return { + type: 'IncludeStatement', + value: token.value, + } + } + }, + transform(node) { + if (node.type === 'IncludeStatement') { + let filePath = node.value + + // 解析文件路径 + // 如果不是绝对路径,则相对于 cwd + if (!isAbsolute(filePath)) { + filePath = resolve(context.options.cwd, filePath) + } + + // 检查文件是否存在 + if (!existsSync(filePath)) { + context.logger.warn(`Include file not found: ${filePath}`) + return createProgramNode() + } + + // 获取当前的包含文件集合 + const currentIncludedFiles = includedFilesStack[includedFilesStack.length - 1] || new Set() + + // 防止循环引用 + if (currentIncludedFiles.has(filePath)) { + context.logger.warn(`Circular include detected: ${filePath}`) + return createProgramNode() + } + + try { + // 创建新的包含文件集合并添加当前文件 + const newIncludedFiles = new Set(currentIncludedFiles) + newIncludedFiles.add(filePath) + includedFilesStack.push(newIncludedFiles) + + // 读取文件内容 + const fileContent = readFileSync(filePath, 'utf-8') + + // 递归处理被包含的文件 + const processedContent = context.transform(fileContent, filePath) + + // 弹出当前层级的包含文件集合 + includedFilesStack.pop() + + // 如果文件经过了预处理,返回处理后的代码 + if (processedContent) { + return { + type: 'CodeStatement', + value: processedContent, + } + } + + // 如果没有预处理指令,直接返回原始内容 + return { + type: 'CodeStatement', + value: fileContent, + } + } + catch (error) { + // 确保出错时也弹出栈 + includedFilesStack.pop() + context.logger.error(`Error including file ${filePath}: ${error}`) + return createProgramNode() + } + } + }, + generate(node, comment) { + if (node.type === 'IncludeStatement' && comment) { + return `${comment.start} #include "${node.value}" ${comment.end}` + } + }, + } +}) diff --git a/src/core/directives/index.ts b/src/core/directives/index.ts index 2a3e3cd..f3734f3 100644 --- a/src/core/directives/index.ts +++ b/src/core/directives/index.ts @@ -1,3 +1,4 @@ export * from './define' export * from './if' +export * from './include' export * from './message' diff --git a/src/core/types/node.ts b/src/core/types/node.ts index f68f5fd..f1394c2 100644 --- a/src/core/types/node.ts +++ b/src/core/types/node.ts @@ -29,3 +29,9 @@ export interface MessageStatement extends SimpleNode { kind: 'error' | 'warning' | 'info' value: string } + +export interface IncludeStatement extends SimpleNode { + type: 'IncludeStatement' + value: string + sourceFile?: string +} diff --git a/src/core/types/token.ts b/src/core/types/token.ts index d43b43b..0cac68a 100644 --- a/src/core/types/token.ts +++ b/src/core/types/token.ts @@ -17,3 +17,8 @@ export interface MessageToken extends SimpleToken { type: 'error' | 'warning' | 'info' value: string } + +export interface IncludeToken extends SimpleToken { + type: 'include' + value: string +} diff --git a/src/core/unplugin.ts b/src/core/unplugin.ts index 4c0e2aa..5e6d045 100644 --- a/src/core/unplugin.ts +++ b/src/core/unplugin.ts @@ -3,13 +3,13 @@ import type { UserOptions } from '../types' import remapping from '@jridgewell/remapping' import { createUnplugin } from 'unplugin' import { Context } from './context' -import { ifDirective, MessageDirective, theDefineDirective } from './directives' +import { ifDirective, includeDirective, MessageDirective, theDefineDirective } from './directives' export const unpluginFactory: UnpluginFactory = ( options, ) => { // @ts-expect-error ignore - const ctx = new Context({ ...options, directives: [ifDirective, theDefineDirective, MessageDirective, ...options?.directives ?? []] }) + const ctx = new Context({ ...options, directives: [ifDirective, theDefineDirective, includeDirective, MessageDirective, ...options?.directives ?? []] }) return { name: 'unplugin-preprocessor-directives', enforce: 'pre', diff --git a/test/__snapshots__/include.test.ts.snap b/test/__snapshots__/include.test.ts.snap new file mode 100644 index 0000000..0f62485 --- /dev/null +++ b/test/__snapshots__/include.test.ts.snap @@ -0,0 +1,55 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`include > should include a simple file 1`] = ` +"// Main file +const mainValue = 'main'; + +// Base file +const baseValue = 'base'; + +// After base include +const afterBase = 'after'; + +// File with directives +const devMode = false; + +// End of main +const endValue = 'end'; +" +`; + +exports[`include > should process directives in included files (DEV=false) 1`] = ` +"// Main file +const mainValue = 'main'; + +// Base file +const baseValue = 'base'; + +// After base include +const afterBase = 'after'; + +// File with directives +const devMode = false; + +// End of main +const endValue = 'end'; +" +`; + +exports[`include > should process directives in included files (DEV=true) 1`] = ` +"// Main file +const mainValue = 'main'; + +// Base file +const baseValue = 'base'; + +// After base include +const afterBase = 'after'; + +// File with directives +const devMode = true; + +// End of main +const endValue = 'end'; +" +`; diff --git a/test/fixtures/include-base.txt b/test/fixtures/include-base.txt new file mode 100644 index 0000000..660b4a8 --- /dev/null +++ b/test/fixtures/include-base.txt @@ -0,0 +1,2 @@ +// Base file +const baseValue = 'base'; diff --git a/test/fixtures/include-circular-a.txt b/test/fixtures/include-circular-a.txt new file mode 100644 index 0000000..f508b88 --- /dev/null +++ b/test/fixtures/include-circular-a.txt @@ -0,0 +1,3 @@ +// File A +const fileA = 'A'; +// #include "include-circular-b.txt" diff --git a/test/fixtures/include-circular-b.txt b/test/fixtures/include-circular-b.txt new file mode 100644 index 0000000..7597c66 --- /dev/null +++ b/test/fixtures/include-circular-b.txt @@ -0,0 +1,3 @@ +// File B +const fileB = 'B'; +// #include "include-circular-a.txt" diff --git a/test/fixtures/include-main.txt b/test/fixtures/include-main.txt new file mode 100644 index 0000000..5a2c3d3 --- /dev/null +++ b/test/fixtures/include-main.txt @@ -0,0 +1,12 @@ +// Main file +const mainValue = 'main'; + +// #include "include-base.txt" + +// After base include +const afterBase = 'after'; + +// #include "include-with-directives.txt" + +// End of main +const endValue = 'end'; diff --git a/test/fixtures/include-with-directives.txt b/test/fixtures/include-with-directives.txt new file mode 100644 index 0000000..0f12e15 --- /dev/null +++ b/test/fixtures/include-with-directives.txt @@ -0,0 +1,6 @@ +// File with directives +// #if DEV +const devMode = true; +// #else +const devMode = false; +// #endif diff --git a/test/include.test.ts b/test/include.test.ts new file mode 100644 index 0000000..31765af --- /dev/null +++ b/test/include.test.ts @@ -0,0 +1,58 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { Context, ifDirective, includeDirective, theDefineDirective } from '../src' + +describe('include', () => { + const root = resolve(__dirname, './fixtures') + const context = new Context({ + cwd: root, + // @ts-expect-error ignore + directives: [includeDirective, ifDirective, theDefineDirective], + }) + + it('should include a simple file', () => { + const code = readFileSync(resolve(root, 'include-main.txt'), 'utf-8') + context.env.DEV = false + const result = context.transform(code, resolve(root, 'include-main.txt')) + expect(result).toBeDefined() + expect(result).toContain('baseValue') + expect(result).toMatchSnapshot() + }) + + it('should process directives in included files (DEV=true)', () => { + const code = readFileSync(resolve(root, 'include-main.txt'), 'utf-8') + context.env.DEV = true + const result = context.transform(code, resolve(root, 'include-main.txt')) + expect(result).toBeDefined() + expect(result).toContain('devMode = true') + expect(result).not.toContain('devMode = false') + expect(result).toMatchSnapshot() + }) + + it('should process directives in included files (DEV=false)', () => { + const code = readFileSync(resolve(root, 'include-main.txt'), 'utf-8') + context.env.DEV = false + const result = context.transform(code, resolve(root, 'include-main.txt')) + expect(result).toBeDefined() + expect(result).toContain('devMode = false') + expect(result).not.toContain('devMode = true') + expect(result).toMatchSnapshot() + }) + + it('should handle non-existent files gracefully', () => { + const code = `// #include "non-existent-file.txt"\nconst test = 'test';` + const result = context.transform(code, 'test.js') + expect(result).toBeDefined() + expect(result).toContain('const test') + }) + + it('should detect and prevent circular includes', () => { + const code = readFileSync(resolve(root, 'include-circular-a.txt'), 'utf-8') + const result = context.transform(code, resolve(root, 'include-circular-a.txt')) + expect(result).toBeDefined() + // 应该包含 fileA 和 fileB,但不会无限循环 + expect(result).toContain('fileA') + expect(result).toContain('fileB') + }) +}) From f140ed961656d3e185075cc0492852d4b82f6061 Mon Sep 17 00:00:00 2001 From: kejun Date: Tue, 4 Nov 2025 00:15:03 +0800 Subject: [PATCH 2/2] docs: add documentation for #include directive with usage examples and warnings --- README.md | 31 +++++++++++++++++++++++++++++++ README.zh-cn.md | 31 +++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/README.md b/README.md index 6941b6e..8cf50c4 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,37 @@ Of course, it can also be combined with conditional compilation: // #endif ``` +### `#include` directive + +You can use the `#include` directive to include the contents of other files into the current file. The included files are also processed by the preprocessor. + +> [!WARNING] +> The `#include` directive is a **compile-time text replacement tool**, primarily intended for these scenarios: +> - Including different configuration code snippets in different environments +> - Combining with conditional compilation to include different code based on compilation conditions +> - Sharing code snippets that require preprocessing +> +> **It cannot and should not replace:** +> - JavaScript/TypeScript `import` or `require` - for modularization and dependency management +> - CSS `@import` - for stylesheet modularization +> - HTML template systems or component systems +> +> If you simply want to modularize your code, please use the language's native module system. Only use `#include` when you need compile-time processing and conditional inclusion. + +this directive supports the following two syntaxes: + +```ts +// #include "path/to/file" +or +// #include +``` + +> [!NOTE] +> 1. **Circular references**: If file A includes file B, and file B includes file A, circular references will be automatically detected and prevented, processing only once +> 2. **Path resolution**: Relative paths are resolved relative to the configured working directory (`cwd`) +> 3. **File extensions**: Any type of text file can be included, not limited to `.js` files +> 4. **Nested processing**: Included files are fully processed by the preprocessor, so all supported directives can be used + ## Custom directive You can used `defineDirective` to define your own directive. diff --git a/README.zh-cn.md b/README.zh-cn.md index b13913c..97e3021 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -208,6 +208,37 @@ class MyClass { // #endif ``` +### `#include` 指令 + +您可以使用 `#include` 指令将其他文件的内容包含到当前文件中。被包含的文件也会经过预处理器处理。 + +> [!WARNING] +> `#include` 指令是一个**编译时文本替换工具**,主要用于以下场景: +> - 在不同环境下包含不同的配置代码片段 +> - 与条件编译结合使用,根据编译条件包含不同的代码 +> - 共享需要预处理的代码片段 +> +> **它不能也不应该替代:** +> - JavaScript/TypeScript 的 `import` 或 `require` - 用于模块化和依赖管理 +> - CSS 的 `@import` - 用于样式表的模块化 +> - HTML 的模板系统或组件系统 +> +> 如果您只是想要模块化代码,请使用语言原生的模块系统。只有在需要编译时处理和条件包含时才使用 `#include`。 + +该指令支持以下两种语法: + +```ts +// #include "path/to/file" +or +// #include +``` + +> [!NOTE] +> 1. **循环引用**: 如果文件 A 包含文件 B,而文件 B 又包含文件 A,会自动检测并阻止循环引用,只处理一次 +> 2. **路径解析**: 相对路径是相对于配置的工作目录(`cwd`)解析的 +> 3. **文件扩展名**: 可以包含任何类型的文本文件,不限于 `.js` 文件 +> 4. **嵌套处理**: 包含的文件会完整地通过预处理器,所以可以使用所有支持的指令 + ## 自定义指令 您可以使用 `defineDirective` 定义自己的指令。