load-oxfmt-config 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,7 +33,7 @@ yarn add oxfmt load-oxfmt-config
33
33
  pnpm add oxfmt load-oxfmt-config
34
34
  ```
35
35
 
36
- > `oxfmt >=0.68.0` is a peer dependency and is installed alongside this package.
36
+ > `oxfmt >=0.70.0` is a peer dependency and is installed alongside this package.
37
37
  > Node.js `^22.13.0 || >=24` is supported.
38
38
 
39
39
  ## Usage
@@ -78,6 +78,27 @@ const result = await loadOxfmtConfig({
78
78
  })
79
79
  ```
80
80
 
81
+ ### Resolve EditorConfig for One File
82
+
83
+ Use `loadOxfmtConfigForFile()` when formatting a specific file, especially when
84
+ its oxfmt config and `.editorconfig` live in different directories:
85
+
86
+ ```ts
87
+ import { loadOxfmtConfigForFile } from 'load-oxfmt-config'
88
+
89
+ const result = await loadOxfmtConfigForFile({
90
+ cwd: '/path/to/project',
91
+ filepath: 'packages/app/src/index.ts',
92
+ })
93
+ ```
94
+
95
+ This accepts the same options as `loadOxfmtConfig()`, with `filepath` required.
96
+ EditorConfig sections are matched relative to their own directory and applied
97
+ in source order, including later `[*]` sections. The resulting EditorConfig
98
+ defaults are merged below explicit oxfmt root options. Explicit oxfmt `overrides`
99
+ remain in the returned config and must still be applied by the caller, relative
100
+ to `result.dirname`. Config metadata retains the same meaning as in the static API.
101
+
81
102
  ### Get Config Metadata
82
103
 
83
104
  ```ts
@@ -344,6 +365,16 @@ Whether to use in-memory cache.
344
365
 
345
366
  Whether to include ignore patterns defined in the resolved config file.
346
367
 
368
+ #### `includeDefaultIgnores`
369
+
370
+ - **Type:** `boolean`
371
+ - **Default:** `true`
372
+
373
+ Whether to apply default directories, lockfiles, and ignore files (`.gitignore`,
374
+ Git info exclude, `.prettierignore`, and explicit `ignorePath` files).
375
+ Set to `false` to evaluate only config `ignorePatterns`. This does not disable
376
+ config loading or matching; use the separate config options for that.
377
+
347
378
  #### `loadConfigForIgnorePatterns`
348
379
 
349
380
  - **Type:** `boolean`
@@ -437,7 +468,12 @@ The loader reads the nearest `.editorconfig` file and maps the subset of fields
437
468
  - `insert_final_newline` → `insertFinalNewline`
438
469
  - `quote_type` → `singleQuote`
439
470
 
440
- Only `[*]` is treated as a global section to match oxfmt. Other sections such as `[**]` and `[*.ts]` are converted into returned `overrides` entries.
471
+ In the static API, `[*]` provides root defaults. A `[*]` section after scoped
472
+ sections also generates an override to preserve source order. Other sections
473
+ such as `[**]` and `[*.ts]` are converted into returned `overrides` entries;
474
+ basename patterns such as `*.ts` are normalized to `**/*.ts` to match descendants.
475
+ The per-file API evaluates matching sections directly, without rebasing them to
476
+ the oxfmt config directory.
441
477
 
442
478
  ## Ignore Strategy
443
479
 
@@ -485,7 +521,12 @@ Section-specific `.editorconfig` entries are represented as generated `overrides
485
521
 
486
522
  ## Limitations
487
523
 
488
- `loadOxfmtConfig()` returns a static merged `OxfmtOptions` shape. That means `.editorconfig` support is represented as merged root + overrides config data, not as per-file runtime evaluation. In practice this works well for common root settings and section-based overrides, but it is not a full replacement for oxfmt's own file-by-file config resolution.
524
+ `loadOxfmtConfig()` returns a static merged `OxfmtOptions` shape. Its generated
525
+ EditorConfig globs cannot represent every relationship between config directories,
526
+ particularly an ancestor EditorConfig with a nested oxfmt config. Use
527
+ `loadOxfmtConfigForFile()` to resolve EditorConfig against its original directory
528
+ for a specific target file. Both APIs retain the nearest-file discovery strategy;
529
+ they do not merge multiple EditorConfig files.
489
530
 
490
531
  ## Error Handling
491
532
 
@@ -503,16 +544,19 @@ try {
503
544
 
504
545
  ## Caching Behavior
505
546
 
506
- The caching system maintains two separate caches:
547
+ The caching system maintains bounded caches for:
507
548
 
508
549
  1. **Path Resolution Cache:** Stores resolved config file paths
509
- 2. **Config Content Cache:** Stores parsed configuration objects
550
+ 2. **Config Sources:** Shares parsed oxfmt files and EditorConfig sections between target files
551
+ 3. **Config Results:** Stores merged static or per-file configuration objects
552
+ 4. **EditorConfig Matchers:** Reuses compiled section patterns
510
553
 
511
554
  **Cache keys are based on:**
512
555
 
513
556
  - `cwd` + `configPath` for path resolution
514
557
  - or `dirname(filepath)` + `configPath` when `filepath` is provided and nested lookup is enabled
515
558
  - Resolved oxfmt path and resolved `.editorconfig` path for config content
559
+ - The target file path for per-file results, isolated from static results
516
560
 
517
561
  **Cache invalidation:**
518
562
 
package/dist/index.d.mts CHANGED
@@ -60,6 +60,12 @@ export interface IsOxfmtIgnoredOptions {
60
60
  * @default true
61
61
  */
62
62
  includeConfigIgnorePatterns?: boolean;
63
+ /**
64
+ * Apply default directories, lockfiles, and ignore files independently of config patterns.
65
+ *
66
+ * @default true
67
+ */
68
+ includeDefaultIgnores?: boolean;
63
69
  /**
64
70
  * Whether to load resolved oxfmt config when evaluating config ignore patterns.
65
71
  *
@@ -203,6 +209,16 @@ export interface OxfmtOptions extends FormatConfig {
203
209
  * ```
204
210
  */
205
211
  export declare function loadOxfmtConfig(options?: LoadOxfmtConfigOptions): Promise<LoadOxfmtConfigResult>;
212
+ /**
213
+ * Load config with EditorConfig fallbacks resolved for one file.
214
+ * Explicit oxfmt overrides remain unevaluated and retain their config-relative globs.
215
+ *
216
+ * @param options - Loader settings with the target file path.
217
+ * @returns Config with per-file EditorConfig defaults and config metadata.
218
+ */
219
+ export declare function loadOxfmtConfigForFile(options: LoadOxfmtConfigOptions & {
220
+ filepath: string;
221
+ }): Promise<LoadOxfmtConfigResult>;
206
222
  //#endregion
207
223
  //#region src/config.d.ts
208
224
  /**
package/dist/index.mjs CHANGED
@@ -7,6 +7,7 @@ import { readFile, realpath, stat } from "node:fs/promises";
7
7
  import { pathToFileURL } from "node:url";
8
8
  import { parse, printParseErrorCode } from "jsonc-parser";
9
9
  import { parseBuffer } from "editorconfig";
10
+ import { Minimatch } from "minimatch";
10
11
  import ignore from "ignore";
11
12
  //#region src/constants.ts
12
13
  /**
@@ -479,6 +480,11 @@ function requireFreshModule(resolvedPath) {
479
480
  //#endregion
480
481
  //#region src/editorconfig.ts
481
482
  /**
483
+ * Shared input caches for static and per-file EditorConfig resolution.
484
+ */
485
+ const sectionCache = /* @__PURE__ */ new Map();
486
+ const matcherCache = /* @__PURE__ */ new Map();
487
+ /**
482
488
  * Builds the cache key used for resolved EditorConfig lookups.
483
489
  *
484
490
  * @param resolveKey - The base resolution key for the current lookup.
@@ -534,21 +540,34 @@ function mergeRootOptions(oxfmtConfig, editorconfigRootOptions) {
534
540
  *
535
541
  * @param editorconfigPath - The absolute path to the .editorconfig file.
536
542
  * @param anchorDir - The directory used to rebase section patterns.
543
+ * @param options - Optional per-file resolution and cache controls.
544
+ * @param options.filepath - Resolve matching sections for this absolute file path.
545
+ * @param options.useCache - Reuse parsed sections and compiled matchers.
537
546
  * @returns Parsed EditorConfig data ready to merge into oxfmt config.
538
547
  */
539
- async function readEditorconfigFromFile(editorconfigPath, anchorDir) {
540
- const content = await readFile(editorconfigPath);
541
- const parsedSections = parseBuffer(content);
548
+ async function readEditorconfigFromFile(editorconfigPath, anchorDir, options = {}) {
549
+ const useCache = options.useCache !== false;
550
+ const readSections = async () => parseBuffer(await readFile(editorconfigPath));
551
+ const parsedSections = useCache ? await cachePromise(sectionCache, editorconfigPath, readSections) : await readSections();
542
552
  const editorconfigDir = dirname(editorconfigPath);
543
553
  const rootOptions = {};
544
554
  const overrides = [];
555
+ const relativeFile = options.filepath ? toPosixPath(relative(editorconfigDir, options.filepath)) : void 0;
556
+ if (relativeFile !== void 0 && isPathOutsideBase(relativeFile)) return {
557
+ overrides,
558
+ rootOptions
559
+ };
545
560
  for (const [sectionName, sectionBody] of parsedSections) {
546
561
  if (!sectionName) continue;
547
562
  const mappedOptions = mapEditorconfigSectionToOptions(sectionBody);
548
563
  if (Object.keys(mappedOptions).length === 0) continue;
564
+ if (relativeFile !== void 0) {
565
+ if (matchesEditorconfigSection(sectionName, relativeFile, useCache)) Object.assign(rootOptions, mappedOptions);
566
+ continue;
567
+ }
549
568
  if (isEditorconfigGlobalSection(sectionName)) {
550
569
  Object.assign(rootOptions, mappedOptions);
551
- continue;
570
+ if (overrides.length === 0) continue;
552
571
  }
553
572
  overrides.push({
554
573
  options: mappedOptions,
@@ -617,6 +636,37 @@ function mapEditorconfigSectionToOptions(section) {
617
636
  return options;
618
637
  }
619
638
  /**
639
+ * Match a section against its source-relative file path without oxfmt rebasing.
640
+ *
641
+ * @param pattern - Original section name.
642
+ * @param filepath - File path relative to the EditorConfig directory.
643
+ * @param useCache - Whether compiled matchers can be reused.
644
+ * @returns Whether the section applies to the file.
645
+ */
646
+ function matchesEditorconfigSection(pattern, filepath, useCache) {
647
+ let matcher = useCache ? getCacheValue(matcherCache, pattern) : void 0;
648
+ if (!matcher) {
649
+ matcher = new Minimatch(normalizeEditorconfigPattern(pattern), {
650
+ dot: true,
651
+ nocomment: true,
652
+ noext: true,
653
+ nonegate: true
654
+ });
655
+ if (useCache) setCacheValue(matcherCache, pattern, matcher);
656
+ }
657
+ return matcher.match(filepath);
658
+ }
659
+ /**
660
+ * Keep basename patterns recursive without changing path-scoped patterns.
661
+ *
662
+ * @param pattern - Original EditorConfig section name.
663
+ * @returns Pattern suitable for matching a path relative to EditorConfig.
664
+ */
665
+ function normalizeEditorconfigPattern(pattern) {
666
+ if (pattern === "*" || pattern === "**") return "**";
667
+ return pattern.includes("/") ? pattern : `**/${pattern}`;
668
+ }
669
+ /**
620
670
  * Parses an EditorConfig boolean string.
621
671
  *
622
672
  * @param value - The raw EditorConfig value.
@@ -677,9 +727,10 @@ function parseEditorconfigTabWidth(section, useTabs) {
677
727
  * @returns The rebased glob pattern.
678
728
  */
679
729
  function rebaseEditorconfigPattern(pattern, editorconfigDir, anchorDir) {
730
+ const normalizedPattern = normalizeEditorconfigPattern(pattern);
680
731
  const relativePrefix = toPosixPath(relative(anchorDir, editorconfigDir));
681
- if (!relativePrefix || relativePrefix === ".") return pattern;
682
- return `${relativePrefix}/${pattern.replace(/^\//u, "")}`;
732
+ if (!relativePrefix || relativePrefix === ".") return normalizedPattern;
733
+ return `${relativePrefix}/${normalizedPattern.replace(/^\//u, "")}`;
683
734
  }
684
735
  //#endregion
685
736
  //#region src/core.ts
@@ -692,6 +743,10 @@ const resolveCache = /* @__PURE__ */ new Map();
692
743
  */
693
744
  const configCache = /* @__PURE__ */ new Map();
694
745
  /**
746
+ * Share raw config reads across files without caching file-specific fallbacks.
747
+ */
748
+ const sourceCache = /* @__PURE__ */ new Map();
749
+ /**
695
750
  * Resolve config + editorconfig and return merged config with metadata.
696
751
  *
697
752
  * @param options - Loader settings.
@@ -706,6 +761,26 @@ const configCache = /* @__PURE__ */ new Map();
706
761
  * ```
707
762
  */
708
763
  async function loadOxfmtConfig(options = {}) {
764
+ return loadConfig(options);
765
+ }
766
+ /**
767
+ * Load config with EditorConfig fallbacks resolved for one file.
768
+ * Explicit oxfmt overrides remain unevaluated and retain their config-relative globs.
769
+ *
770
+ * @param options - Loader settings with the target file path.
771
+ * @returns Config with per-file EditorConfig defaults and config metadata.
772
+ */
773
+ async function loadOxfmtConfigForFile(options) {
774
+ return loadConfig(options, true);
775
+ }
776
+ /**
777
+ * Share discovery and caches between static and per-file loading.
778
+ *
779
+ * @param options - Loader settings.
780
+ * @param resolveEditorconfig - Resolve EditorConfig sections against the target file.
781
+ * @returns Loaded config and metadata.
782
+ */
783
+ async function loadConfig(options, resolveEditorconfig = false) {
709
784
  const useCache = options.useCache !== false;
710
785
  const cwd = resolve(options.cwd || process.cwd());
711
786
  const filepath = options.filepath ? resolve(cwd, options.filepath) : void 0;
@@ -721,12 +796,17 @@ async function loadOxfmtConfig(options = {}) {
721
796
  const resolvedPath = useCache ? await cachePromise(resolveCache, resolveKey, () => resolveOxfmtrcPath(configLookupCwd, options.configPath)) : await resolveOxfmtrcPath(configLookupCwd, options.configPath);
722
797
  const editorconfigPath = useEditorconfig ? await (useCache ? cachePromise(resolveCache, editorconfigResolveKey, () => resolveEditorconfigPath(editorconfigSearchDir, onlyCwd)) : resolveEditorconfigPath(editorconfigSearchDir, onlyCwd)) : void 0;
723
798
  const anchorDir = dirname(resolvedPath || editorconfigPath || cwd);
799
+ const configKey = getConfigCacheKey(resolvedPath, editorconfigPath, JSON.stringify([resolveKey, resolveEditorconfig ? filepath : null]));
724
800
  const loadTask = async () => {
725
- const oxfmtConfig = resolvedPath ? await readConfigFromFile(resolvedPath, { useCache }).catch((error) => {
801
+ const readConfig = async () => resolvedPath ? await readConfigFromFile(resolvedPath, { useCache }).catch((error) => {
726
802
  throw new Error(`Failed to parse oxfmt configuration file at ${resolvedPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
727
803
  }) : {};
804
+ const oxfmtConfig = useCache && resolvedPath ? await cachePromise(sourceCache, resolvedPath, readConfig) : await readConfig();
728
805
  if (!editorconfigPath) return oxfmtConfig;
729
- const editorconfigData = await readEditorconfigFromFile(editorconfigPath, anchorDir);
806
+ const editorconfigData = await readEditorconfigFromFile(editorconfigPath, anchorDir, {
807
+ filepath: resolveEditorconfig ? filepath : void 0,
808
+ useCache
809
+ });
730
810
  const mergedConfig = mergeRootOptions(oxfmtConfig, editorconfigData.rootOptions);
731
811
  const mergedOverrides = mergeOverrides(oxfmtConfig, editorconfigData.overrides);
732
812
  if (!mergedOverrides) return mergedConfig;
@@ -735,12 +815,8 @@ async function loadOxfmtConfig(options = {}) {
735
815
  overrides: mergedOverrides
736
816
  };
737
817
  };
738
- const hasNoConfigSources = !resolvedPath && !editorconfigPath;
739
818
  return {
740
- config: await (async () => {
741
- if (hasNoConfigSources) return useCache ? await cachePromise(configCache, getConfigCacheKey(resolvedPath, editorconfigPath, resolveKey), () => Promise.resolve({})) : {};
742
- return useCache ? await cachePromise(configCache, getConfigCacheKey(resolvedPath, editorconfigPath, resolveKey), loadTask) : await loadTask();
743
- })(),
819
+ config: useCache ? await cachePromise(configCache, configKey, loadTask) : await loadTask(),
744
820
  ...resolvedPath ? {
745
821
  dirname: dirname(resolvedPath),
746
822
  filepath: resolvedPath
@@ -779,37 +855,10 @@ async function isOxfmtIgnored(options) {
779
855
  const loadConfigForIgnorePatterns = options.loadConfigForIgnorePatterns !== false;
780
856
  const cwd = resolve(options.cwd ?? process.cwd());
781
857
  const filepath = isAbsolute(options.filepath) ? resolve(options.filepath) : resolve(cwd, options.filepath);
782
- if (isDefaultIgnoredDir(filepath, options.withNodeModules ? { withNodeModules: true } : {})) return {
783
- ignored: true,
784
- reason: "default-dir"
785
- };
786
- if (isLockfile(filepath)) return {
787
- ignored: true,
788
- reason: "lockfile"
789
- };
790
- const explicitIgnorePaths = (typeof options.ignorePath === "string" ? [options.ignorePath] : options.ignorePath)?.map((path) => resolveIgnoreFilePath(path, cwd));
791
- const { paths: gitignorePaths, repoRoot } = await collectGitignorePaths(filepath);
792
- const gitignoreResult = await matchIgnoreFileChain(filepath, [...gitignorePaths].reverse().map((path) => ({ path })), useCache, true);
793
- if (gitignoreResult.ignored) return {
794
- ignored: true,
795
- reason: "gitignore"
796
- };
797
- if (repoRoot && !gitignoreResult.matched) {
798
- const infoExcludePath = await resolveGitInfoExcludePath(repoRoot);
799
- if (infoExcludePath && await matchIgnoreFile(filepath, infoExcludePath, useCache, repoRoot)) return {
800
- ignored: true,
801
- reason: "git-info-exclude"
802
- };
858
+ if (options.includeDefaultIgnores !== false) {
859
+ const defaultResult = await resolveDefaultIgnores(filepath, cwd, options, useCache);
860
+ if (defaultResult.ignored) return defaultResult;
803
861
  }
804
- if (explicitIgnorePaths && explicitIgnorePaths.length > 0) {
805
- if ((await matchIgnoreFileChain(filepath, explicitIgnorePaths.map((path) => ({ path })), useCache)).ignored) return {
806
- ignored: true,
807
- reason: "ignore-path"
808
- };
809
- } else if (await matchIgnoreFile(filepath, resolve(cwd, ".prettierignore"), useCache)) return {
810
- ignored: true,
811
- reason: "prettierignore"
812
- };
813
862
  if (!loadConfigForIgnorePatterns) return { ignored: false };
814
863
  const configResult = await loadOxfmtConfig({
815
864
  cwd,
@@ -1022,6 +1071,49 @@ function relativeSafe(from, to) {
1022
1071
  return toPosixPath(relative(from, to));
1023
1072
  }
1024
1073
  /**
1074
+ * Evaluate global ignore rules separately from explicit config patterns.
1075
+ *
1076
+ * @param filepath - Absolute target file path.
1077
+ * @param cwd - Base directory for ignore file resolution.
1078
+ * @param options - Ignore file and node_modules settings.
1079
+ * @param useCache - Whether ignore matchers can be reused.
1080
+ * @returns Global ignore status.
1081
+ */
1082
+ async function resolveDefaultIgnores(filepath, cwd, options, useCache) {
1083
+ if (isDefaultIgnoredDir(filepath, options.withNodeModules ? { withNodeModules: true } : {})) return {
1084
+ ignored: true,
1085
+ reason: "default-dir"
1086
+ };
1087
+ if (isLockfile(filepath)) return {
1088
+ ignored: true,
1089
+ reason: "lockfile"
1090
+ };
1091
+ const explicitIgnorePaths = (typeof options.ignorePath === "string" ? [options.ignorePath] : options.ignorePath)?.map((path) => resolveIgnoreFilePath(path, cwd));
1092
+ const { paths: gitignorePaths, repoRoot } = await collectGitignorePaths(filepath);
1093
+ const gitignoreResult = await matchIgnoreFileChain(filepath, [...gitignorePaths].reverse().map((path) => ({ path })), useCache, true);
1094
+ if (gitignoreResult.ignored) return {
1095
+ ignored: true,
1096
+ reason: "gitignore"
1097
+ };
1098
+ if (repoRoot && !gitignoreResult.matched) {
1099
+ const infoExcludePath = await resolveGitInfoExcludePath(repoRoot);
1100
+ if (infoExcludePath && await matchIgnoreFile(filepath, infoExcludePath, useCache, repoRoot)) return {
1101
+ ignored: true,
1102
+ reason: "git-info-exclude"
1103
+ };
1104
+ }
1105
+ if (explicitIgnorePaths && explicitIgnorePaths.length > 0) {
1106
+ if ((await matchIgnoreFileChain(filepath, explicitIgnorePaths.map((path) => ({ path })), useCache)).ignored) return {
1107
+ ignored: true,
1108
+ reason: "ignore-path"
1109
+ };
1110
+ } else if (await matchIgnoreFile(filepath, resolve(cwd, ".prettierignore"), useCache)) return {
1111
+ ignored: true,
1112
+ reason: "prettierignore"
1113
+ };
1114
+ return { ignored: false };
1115
+ }
1116
+ /**
1025
1117
  * Resolve the git info exclude file from a repo root.
1026
1118
  *
1027
1119
  * Worktrees and submodules store `.git` as a file containing a `gitdir:` pointer.
@@ -1079,4 +1171,4 @@ function validateConfigIgnorePatterns(patterns) {
1079
1171
  }
1080
1172
  }
1081
1173
  //#endregion
1082
- export { getConfigCacheKey, getResolveCacheKey, isOxfmtIgnored, loadOxfmtConfig, readConfigFromFile, resolveOxfmtrcPath };
1174
+ export { getConfigCacheKey, getResolveCacheKey, isOxfmtIgnored, loadOxfmtConfig, loadOxfmtConfigForFile, readConfigFromFile, resolveOxfmtrcPath };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "load-oxfmt-config",
3
3
  "type": "module",
4
- "version": "0.24.0",
4
+ "version": "0.25.0",
5
5
  "description": "Load and resolve oxfmt configuration files, including explicit JS/TS config paths, and merge supported `.editorconfig` settings for Oxfmt.",
6
6
  "keywords": [
7
7
  "editorconfig",
@@ -43,14 +43,15 @@
43
43
  },
44
44
  "sideEffects": false,
45
45
  "peerDependencies": {
46
- "oxfmt": ">=0.68.0"
46
+ "oxfmt": ">=0.70.0"
47
47
  },
48
48
  "dependencies": {
49
- "@ntnyq/utils": "^0.22.0",
49
+ "@ntnyq/utils": "^0.23.0",
50
50
  "editorconfig": "^3.0.2",
51
51
  "ignore": "^7.0.9",
52
52
  "jiti": "^2.7.0",
53
- "jsonc-parser": "^3.3.1"
53
+ "jsonc-parser": "^3.3.1",
54
+ "minimatch": "~10.2.6"
54
55
  },
55
56
  "engines": {
56
57
  "node": "^22.13.0 || >=24"