# How Vite+ Merges Multiple Configuration Files During Project Migration

> Learn how Vite+ seamlessly merges .oxlintrc and .oxfmtrc config files during project migration using Rust AST transformations. Simplify your setup.

- Repository: [VoidZero/vite-plus](https://github.com/voidzero-dev/vite-plus)
- Tags: how-to-guide
- Published: 2026-03-16

---

**Vite+ detects existing `.oxlintrc` and `.oxfmtrc` files, ensures a target [`vite.config.ts`](https://github.com/voidzero-dev/vite-plus/blob/main/vite.config.ts) exists, and uses Rust-based AST transformations to inject the JSON configurations under `lint` and `fmt` keys before removing the original files.**

When migrating a project to the unified `voidzero-dev/vite-plus` toolchain, consolidating scattered Ox configuration files into a single Vite configuration is essential. The migration CLI implements a three-stage pipeline that automatically discovers legacy configuration files and merges them into [`vite.config.ts`](https://github.com/voidzero-dev/vite-plus/blob/main/vite.config.ts) using native Rust bindings and programmatic AST manipulation.

## Stage 1: Detecting Ox Configuration Files

The migration begins with **config detection** in [`packages/cli/src/migration/detector.ts`](https://github.com/voidzero-dev/vite-plus/blob/main/packages/cli/src/migration/detector.ts). The `detectConfigs()` function (lines 42‑66) scans the project root for existing Vite and Ox configuration files, returning a `ConfigFiles` object that records which paths exist.

```typescript
// packages/cli/src/migration/detector.ts
export function detectConfigs(projectPath: string): ConfigFiles {
  const viteConfig = findViteConfig(projectPath);
  const oxlintConfig = findFile(projectPath, ['.oxlintrc', '.oxlintrc.json']);
  const oxfmtConfig  = findFile(projectPath, ['.oxfmtrc',  '.oxfmtrc.json']);
  // ...
  return { viteConfig, oxlintConfig, oxfmtConfig, /* ... */ };
}

```

This utility looks for exact filenames—including optional `.json` extensions—and identifies whether the project uses `.oxlintrc` for linting rules or `.oxfmtrc` for formatting preferences.

## Stage 2: Ensuring a Target Vite Configuration

Before merging can occur, the migrator ensures a valid target exists. In [`packages/cli/src/migration/migrator.ts`](https://github.com/voidzero-dev/vite-plus/blob/main/packages/cli/src/migration/migrator.ts), the `ensureViteConfig()` function (around line 430) checks the `ConfigFiles` object returned by the detector. If no `vite.config.*` is present, it generates a minimal template:

```typescript
// packages/cli/src/migration/migrator.ts
const viteConfig = ensureViteConfig(projectPath, configs, silent, report);

```

When creation is necessary, the function writes a temporary file exporting `defineConfig({})`. This guarantees that the subsequent merge step has a valid TypeScript source file to transform, regardless of the project's original setup.

## Stage 3: The Rust-Powered Merge Process

The actual merging logic bridges JavaScript and Rust via NAPI bindings. For each discovered JSON config, `mergeAndRemoveJsonConfig()` (lines 59‑73) invokes the native `mergeJsonConfig` function.

### The JavaScript Bridge

The TypeScript wrapper handles file I/O and cleanup after the Rust transformation completes:

```typescript
// packages/cli/src/migration/migrator.ts
const result = mergeJsonConfig(fullViteConfigPath, fullJsonConfigPath, configKey);
if (result.updated) {
  fs.writeFileSync(fullViteConfigPath, result.content);
  fs.unlinkSync(fullJsonConfigPath);   // delete the original .ox* file
}

```

The `mergeJsonConfig` function is exposed through [`packages/cli/binding/src/migration.rs`](https://github.com/voidzero-dev/vite-plus/blob/main/packages/cli/binding/src/migration.rs), acting as the NAPI bridge to the Rust core.

### Core Rust Implementation

Inside [`crates/vite_migration/src/vite_config.rs`](https://github.com/voidzero-dev/vite-plus/blob/main/crates/vite_migration/src/vite_config.rs) (lines 66‑79), the `merge_json_config` function orchestrates the transformation:

```rust
pub fn merge_json_config(
    vite_config_path: &Path,
    json_config_path: &Path,
    config_key: &str,
) -> Result<MergeResult, Error> {
    let vite_config_content = std::fs::read_to_string(vite_config_path)?;
    let js_config = std::fs::read_to_string(json_config_path)?;
    merge_json_config_content(&vite_config_content, &js_config, config_key)
}

```

Before injecting the JSON content, the pipeline cleans the source data. The `strip_schema_property` helper (lines 16‑25) removes `"$schema"` annotations using regex to prevent TypeScript errors when the JSON is embedded as a JavaScript object literal. Additionally, `check_function_callback` (lines 31‑62) inspects the existing Vite config to detect if it uses the arrow-function form `defineConfig(env => ({ ... }))`, storing this metadata in `uses_function_callback`.

### AST-Grep Rule Generation

To handle diverse Vite config patterns—object literals, arrow-function returns, `export default`, and TypeScript `satisfies` expressions—the Rust code generates dynamic **ast-grep** transformation rules. The `generate_merge_rule` function (lines 165‑176) creates six distinct patterns that match common configuration styles:

```rust
fn generate_merge_rule(ts_config: &str, config_key: &str) -> String {
    let indented_config = indent_multiline(ts_config, 4);
    let template = r#"---
id: merge-json-config-object
language: TypeScript
rule:
  pattern: |
    defineConfig({
      $$$CONFIG
    })
fix: |-
  defineConfig({
    __CONFIG_KEY__: __JSON_CONFIG__,
    $$$CONFIG
  })
--- ... (other patterns) "#;

    template
        .replace("__CONFIG_KEY__", config_key)
        .replace("__JSON_CONFIG__", &indented_config)
}

```

The `ast_grep::apply_rules` function executes these generated rules against the Vite config source, returning a `MergeResult` containing the transformed content and an `updated` boolean flag.

## Handling Edge Cases and Config Conflicts

The merger includes safeguards to prevent data loss and syntax errors:

- **Existing key detection**: If the target Vite config already contains a `lint:` or `fmt:` key, the merge is skipped entirely via an early return in the injection logic.
- **JSONC preservation**: Because the JSON file content is interpolated directly into the TypeScript source, trailing commas and `//` comments are preserved through the transformation and later sanitized by the TypeScript parser.
- **Function-style configs**: The ast-grep rules explicitly include patterns for `defineConfig(($PARAMS) => ({ ... }))`, ensuring the new key is correctly inserted inside the returned object literal rather than at the top level.
- **Schema stripping**: The automatic removal of `"$schema"` properties prevents invalid TypeScript syntax when embedding strict JSON schema references into JavaScript object notation.

## Summary

- **Detection**: `detectConfigs()` in [`packages/cli/src/migration/detector.ts`](https://github.com/voidzero-dev/vite-plus/blob/main/packages/cli/src/migration/detector.ts) identifies `.oxlintrc`, `.oxfmtrc`, and existing `vite.config.*` files.
- **Preparation**: `ensureViteConfig()` creates a minimal [`vite.config.ts`](https://github.com/voidzero-dev/vite-plus/blob/main/vite.config.ts) if none exists, providing a transformation target.
- **Transformation**: `mergeAndRemoveJsonConfig()` calls Rust NAPI functions to parse JSON, strip schema properties, generate ast-grep rules, and inject configurations under the `lint` or `fmt` keys.
- **Cleanup**: Original Ox config files are deleted after successful merging, leaving a single consolidated [`vite.config.ts`](https://github.com/voidzero-dev/vite-plus/blob/main/vite.config.ts) containing all migrated settings.

## Frequently Asked Questions

### What happens if a vite.config.ts already exists?

If [`vite.config.ts`](https://github.com/voidzero-dev/vite-plus/blob/main/vite.config.ts) (or `.js`) is present, `ensureViteConfig()` skips creation and uses the existing file as the merge target. The ast-grep rules then attempt to inject the new configuration keys into the existing structure without overwriting unrelated settings.

### Does Vite+ preserve comments in the original JSON config files?

Yes. Because the merger reads the raw JSON (or JSONC) file content and interpolates it directly into the TypeScript source as a string literal, existing comments and trailing commas are preserved through the transformation process. The TypeScript parser handles final validation and formatting.

### How does the merger handle function-based Vite configurations?

The Rust implementation includes specific ast-grep patterns for arrow-function configurations like `defineConfig((env) => ({ ... }))`. The transformation rule places the new `lint` or `fmt` key inside the returned object literal, ensuring compatibility with dynamic configuration functions that depend on environment variables.

### What occurs if the target config already has a lint or fmt key?

The migration logic detects existing keys before attempting injection. If the target configuration already defines the key specified in `configKey` (e.g., `lint`), the merger returns early without making changes, preserving the existing configuration and leaving the original Ox config file in place.