Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path/to/file>
```

> [!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.
Expand Down
31 changes: 31 additions & 0 deletions README.zh-cn.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path/to/file>
```

> [!NOTE]
> 1. **循环引用**: 如果文件 A 包含文件 B,而文件 B 又包含文件 A,会自动检测并阻止循环引用,只处理一次
> 2. **路径解析**: 相对路径是相对于配置的工作目录(`cwd`)解析的
> 3. **文件扩展名**: 可以包含任何类型的文本文件,不限于 `.js` 文件
> 4. **嵌套处理**: 包含的文件会完整地通过预处理器,所以可以使用所有支持的指令

## 自定义指令

您可以使用 `defineDirective` 定义自己的指令。
Expand Down
92 changes: 92 additions & 0 deletions src/core/directives/include.ts
Original file line number Diff line number Diff line change
@@ -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<IncludeToken, IncludeStatement>((context) => {
// 用于跟踪已包含的文件,防止循环引用(每次转换时重置)
const includedFilesStack: Set<string>[] = []

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<string>()

// 防止循环引用
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}`
}
},
}
})
1 change: 1 addition & 0 deletions src/core/directives/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './define'
export * from './if'
export * from './include'
export * from './message'
6 changes: 6 additions & 0 deletions src/core/types/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
5 changes: 5 additions & 0 deletions src/core/types/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,8 @@ export interface MessageToken extends SimpleToken {
type: 'error' | 'warning' | 'info'
value: string
}

export interface IncludeToken extends SimpleToken {
type: 'include'
value: string
}
4 changes: 2 additions & 2 deletions src/core/unplugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserOptions | undefined> = (
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',
Expand Down
55 changes: 55 additions & 0 deletions test/__snapshots__/include.test.ts.snap
Original file line number Diff line number Diff line change
@@ -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';
"
`;
2 changes: 2 additions & 0 deletions test/fixtures/include-base.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Base file
const baseValue = 'base';
3 changes: 3 additions & 0 deletions test/fixtures/include-circular-a.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// File A
const fileA = 'A';
// #include "include-circular-b.txt"
3 changes: 3 additions & 0 deletions test/fixtures/include-circular-b.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// File B
const fileB = 'B';
// #include "include-circular-a.txt"
12 changes: 12 additions & 0 deletions test/fixtures/include-main.txt
Original file line number Diff line number Diff line change
@@ -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';
6 changes: 6 additions & 0 deletions test/fixtures/include-with-directives.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// File with directives
// #if DEV
const devMode = true;
// #else
const devMode = false;
// #endif
58 changes: 58 additions & 0 deletions test/include.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})