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: 23 additions & 8 deletions packages/rstack/src/cli/commands.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { join, resolve } from 'node:path';
import { getConfigState } from '../config.ts';
import { getConfigState, getRstackPluginRuntime, loadRstackConfig } from '../config.ts';
import { insertConfigArg, parseArgs, parseCliArgs } from './args.ts';
import { hasHelpFlag, printCommandHelp } from './help.ts';

Expand Down Expand Up @@ -163,16 +163,13 @@ async function runCheckCLI(args: string[]): Promise<void> {
}

export async function setupCommands(): Promise<void> {
const state = getConfigState();
delete state.configPath;
delete state.invocation;

const { args, configPath } = parseCliArgs(process.argv.slice(2));
const command = args[0];

// Resolved for every command so that a relative `--config` path always means
// the same file: it is anchored to the directory the CLI was invoked in, even
// when the config is later loaded from another directory. The motivating case
// is `rs fmt --lsp`, which loads the config from the LSP workspace root the
// client reports, and that root need not be the process working directory.
getConfigState().configPath = configPath === undefined ? undefined : resolve(configPath);

if (!command || command === '-h' || command === '--help') {
return printCommandHelp('root');
}
Expand All @@ -182,6 +179,17 @@ export async function setupCommands(): Promise<void> {
return;
}

// Anchor a relative `--config` to the directory the CLI was invoked in,
// even when a command later loads it from another directory (for example,
// an LSP workspace root).
state.configPath = configPath === undefined ? undefined : resolve(configPath);
state.invocation = {
cwd: process.cwd(),
command,
args: args.slice(1),
configFilePath: null,
};

if (command === 'lib') {
await runRslibCLI(args.slice(1));
return;
Expand Down Expand Up @@ -239,5 +247,12 @@ export async function setupCommands(): Promise<void> {
return;
}

const loaded = await loadRstackConfig();
const runtime = await getRstackPluginRuntime(loaded);

if (await runtime.runCommand(command, state.invocation.args)) {
return;
}

throw new Error(`Unknown command: ${command}`);
}
93 changes: 89 additions & 4 deletions packages/rstack/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import { resolve } from 'node:path';
import { loadConfig } from '@rstackjs/load-config';
import { logger } from 'rslog';
import type { RsbuildConfigDefinition } from '@rsbuild/core';
import type { RslibConfigDefinition } from '@rslib/core';
import type { RslintConfig } from '@rslint/core';
import type { UserConfig, UserConfigAsyncFn } from '@rspress/core';
import type { RstestConfigExport } from '@rstest/core';
import type { FmtConfigDefinition } from './fmt/types.ts';
import type { RstackConfigMap, RstackPlugins } from './plugin.ts';
import { createPluginRuntime, type RstackPluginRuntime } from './pluginRuntime.ts';
import type { StagedConfig } from './staged.ts';

export type RslintConfigDefinition = RslintConfig | (() => Promise<RslintConfig>);
Expand All @@ -27,10 +31,14 @@ export type Configs = {

export type LoadedRstackConfig = {
configs: Configs;
plugins: RstackPlugins;
filePath: string | null;
dependencies: string[];
};

const loadedPluginRuntimes = new WeakMap<LoadedRstackConfig, Promise<RstackPluginRuntime>>();
const loadedConfigDirectories = new WeakMap<LoadedRstackConfig, string>();

export type LoadRstackConfigOptions = {
/**
* The path to the Rstack config file, can be a relative or absolute path.
Expand All @@ -48,16 +56,26 @@ export type LoadRstackConfigOptions = {

type ConfigSession = {
configs: Configs;
plugins: RstackPlugins;
pluginsDefined: boolean;
active: boolean;
};

export type RstackInvocation = {
cwd: string;
command: string;
args: string[];
configFilePath: string | null;
};

type ConfigState = {
/**
* Config file path from the global `--config` flag. Always absolute: the CLI
* resolves it at parse time so it stays independent of later cwd choices
* (`loadRstackConfig` may be called with an LSP workspace root as `cwd`).
*/
configPath?: string;
invocation?: RstackInvocation;
};

declare global {
Expand All @@ -80,15 +98,51 @@ const getConfigSessionStorage = (): AsyncLocalStorage<ConfigSession> => {

export const getConfigState = (): ConfigState => {
// The CLI and its internal tool config can also be loaded as separate module
// instances. Keep only the CLI config path in its own global state.
// instances. Keep CLI invocation state in its own global state.
if (!globalThis.__rstackCliState) {
globalThis.__rstackCliState = {};
}

return globalThis.__rstackCliState;
};

export const getRstackPluginRuntime = (
config: LoadedRstackConfig,
): Promise<RstackPluginRuntime> => {
const existingRuntime = loadedPluginRuntimes.get(config);
if (existingRuntime) {
return existingRuntime;
}

const invocation = getConfigState().invocation;
const runtime = createPluginRuntime({
plugins: config.plugins,
context: {
cwd: invocation?.cwd ?? loadedConfigDirectories.get(config) ?? process.cwd(),
command: invocation?.command ?? 'programmatic',
args: invocation?.args ?? [],
configFilePath: config.filePath,
},
logger,
});
loadedPluginRuntimes.set(config, runtime);
return runtime;
};

export const applyRstackConfigModifiers = async <K extends keyof RstackConfigMap>(
loaded: LoadedRstackConfig,
kind: K,
config: RstackConfigMap[K],
): Promise<RstackConfigMap[K]> =>
(await getRstackPluginRuntime(loaded)).applyConfigModifiers(kind, config);

type Define = {
/**
* Registers plugins that extend the Rstack CLI.
*
* @see {@link https://rstack.rs/plugins | Rstack plugin guide}
*/
plugins: (plugins: RstackPlugins) => void;
/**
* Defines the Rsbuild config for the app.
*
Expand Down Expand Up @@ -152,20 +206,37 @@ type Define = {
staged: (config: StagedConfig) => void;
};

const setConfig = <T extends keyof Configs>(type: T, config: Configs[T]): void => {
const getActiveConfigSession = (type: string): ConfigSession => {
const session = getConfigSessionStorage().getStore();

if (!session?.active) {
throw new Error(`The "${type}" config must be defined while loading an Rstack config.`);
}

return session;
};

const setConfig = <T extends keyof Configs>(type: T, config: Configs[T]): void => {
const session = getActiveConfigSession(type);

if (type in session.configs) {
throw new Error(`The "${type}" config has already been defined.`);
}
session.configs[type] = config;
};

const setPlugins = (plugins: RstackPlugins): void => {
const session = getActiveConfigSession('plugins');

if (session.pluginsDefined) {
throw new Error('The "plugins" config has already been defined.');
}
session.plugins = plugins;
session.pluginsDefined = true;
};

export const define: Define = {
plugins: setPlugins,
app: (config) => setConfig('app', config),
lib: (config) => setConfig('lib', config),
doc: (config) => setConfig('doc', config),
Expand All @@ -187,10 +258,12 @@ export const loadRstackConfig = async ({
const configPath = configFilePath ?? state.configPath;
const session: ConfigSession = {
configs: {},
plugins: [],
pluginsDefined: false,
active: true,
};

return getConfigSessionStorage().run(session, async () => {
const loadedConfig = await getConfigSessionStorage().run(session, async () => {
try {
const { filePath, dependencies } = await loadConfig({
loader: 'native',
Expand All @@ -209,14 +282,26 @@ export const loadRstackConfig = async ({
}),
});

return {
if (state.invocation) {
state.invocation.configFilePath = filePath;
}

const result = {
configs: session.configs,
plugins: session.plugins,
filePath,
dependencies,
};
loadedConfigDirectories.set(result, resolve(cwd ?? process.cwd()));
return result;
} finally {
session.active = false;
session.configs = {};
session.plugins = [];
session.pluginsDefined = false;
}
});

await getRstackPluginRuntime(loadedConfig);
return loadedConfig;
};
15 changes: 10 additions & 5 deletions packages/rstack/src/fmt/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { performance } from 'node:perf_hooks';
import { color, logger } from 'rslog';
import { parseArgs } from '../cli/args.ts';
import { printCommandHelp } from '../cli/help.ts';
import { loadRstackConfig } from '../config.ts';
import { applyRstackConfigModifiers, loadRstackConfig } from '../config.ts';
import { ensureProjectCacheDir } from '../projectCache.ts';
import { fmtCacheFileName } from './cacheStore.ts';
import { resolveFmtConfig } from './config.ts';
import { resolveFmtConfig, resolveFmtConfigDefinition } from './config.ts';
import { discoverFmtFiles } from './discovery.ts';
import { createRelativePathResolver, toPosixPath } from './pathHelpers.ts';
import { runFmtFiles } from './runner.ts';
Expand Down Expand Up @@ -236,11 +236,16 @@ const logFmtResult = (
};

const loadFmtConfig = async (cwd: string): Promise<ResolvedFmtConfig> => {
const { configs, filePath } = await loadRstackConfig({ cwd });
const loaded = await loadRstackConfig({ cwd });
const config = await applyRstackConfigModifiers(
loaded,
'fmt',
await resolveFmtConfigDefinition(loaded.configs.fmt),
);

return resolveFmtConfig({
definition: configs.fmt,
configFilePath: filePath,
definition: config,
configFilePath: loaded.filePath,
cwd,
});
};
Expand Down
8 changes: 6 additions & 2 deletions packages/rstack/src/fmt/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,16 +135,20 @@ const createOptionsResolver = (config: ResolvedFmtConfig): FmtOptionsResolver =>
};

/** Resolves a formatter config definition and its project root. */
const resolveFmtConfigDefinition = async (
definition: FmtConfigDefinition | undefined,
): Promise<FmtConfig> => (typeof definition === 'function' ? await definition() : definition) ?? {};

const resolveFmtConfig = async ({
definition,
configFilePath,
cwd,
}: ResolveFmtConfigOptions): Promise<ResolvedFmtConfig> => {
const config = typeof definition === 'function' ? await definition() : definition;
const config = await resolveFmtConfigDefinition(definition);
const rootPath = configFilePath ? dirname(configFilePath) : cwd;

return normalizeFmtConfig(config, rootPath);
};

export { createOptionsResolver, normalizeFmtConfig, resolveFmtConfig };
export { createOptionsResolver, normalizeFmtConfig, resolveFmtConfig, resolveFmtConfigDefinition };
export type { FmtOptionsResolver };
11 changes: 11 additions & 0 deletions packages/rstack/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,13 @@
export { define } from './config.ts';
export { runCLI } from './cli/index.ts';
export type { FmtConfig } from './fmt/types.ts';
export type {
RstackCommand,
RstackConfigMap,
RstackLogger,
RstackPlugin,
RstackPluginAPI,
RstackPluginContext,
RstackPlugins,
} from './plugin.ts';
export type { StagedConfig } from './staged.ts';
65 changes: 65 additions & 0 deletions packages/rstack/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { RsbuildConfig } from '@rsbuild/core';
import type { RslibConfig } from '@rslib/core';
import type { RslintConfig } from '@rslint/core';
import type { UserConfig as RspressConfig } from '@rspress/core';
import type { RstestConfig } from '@rstest/core';
import type { FmtConfig } from './fmt/types.ts';
import type { StagedConfig } from './staged.ts';

export type RstackConfigMap = {
app: RsbuildConfig;
lib: RslibConfig;
doc: RspressConfig;
test: RstestConfig;
lint: RslintConfig;
fmt: FmtConfig;
staged: StagedConfig;
};

export type RstackPluginContext = Readonly<{
cwd: string;
command: string;
args: readonly string[];
configFilePath: string | null;
}>;

export interface RstackLogger {
debug(message?: unknown, ...args: unknown[]): void;
info(message?: unknown, ...args: unknown[]): void;
warn(message?: unknown, ...args: unknown[]): void;
error(message?: unknown, ...args: unknown[]): void;
success(message?: unknown, ...args: unknown[]): void;
}

export type RstackCommand = {
name: string;
handler: (args: readonly string[]) => void | Promise<void>;
};

export type RstackPluginAPI = {
readonly context: RstackPluginContext;
readonly logger: RstackLogger;

addCommand: (command: RstackCommand) => void;

modifyConfig: <K extends keyof RstackConfigMap>(
kind: K,
handler: (
config: RstackConfigMap[K],
) => void | RstackConfigMap[K] | Promise<void | RstackConfigMap[K]>,
) => void;
};

export type RstackPlugin = {
name: string;
setup(api: RstackPluginAPI): void | Promise<void>;
};

export type RstackPlugins = Array<
| RstackPlugin
| false
| null
| undefined
| Promise<RstackPlugin | false | null | undefined | RstackPlugins>
| RstackPlugins
>;
Loading