1 |
|
2 | 'use strict';
|
3 |
|
4 | const path = require('path');
|
5 | const loaders = require('./loaders');
|
6 | const readFile = require('./readFile');
|
7 | const cacheWrapper = require('./cacheWrapper');
|
8 | const getDirectory = require('./getDirectory');
|
9 |
|
10 | const MODE_SYNC = 'sync';
|
11 |
|
12 |
|
13 |
|
14 |
|
15 |
|
16 |
|
17 |
|
18 | class Explorer {
|
19 |
|
20 |
|
21 |
|
22 |
|
23 |
|
24 |
|
25 | constructor(options ) {
|
26 | this.loadCache = options.cache ? new Map() : null;
|
27 | this.loadSyncCache = options.cache ? new Map() : null;
|
28 | this.searchCache = options.cache ? new Map() : null;
|
29 | this.searchSyncCache = options.cache ? new Map() : null;
|
30 | this.config = options;
|
31 | this.validateConfig();
|
32 | }
|
33 |
|
34 | clearLoadCache() {
|
35 | if (this.loadCache) {
|
36 | this.loadCache.clear();
|
37 | }
|
38 | if (this.loadSyncCache) {
|
39 | this.loadSyncCache.clear();
|
40 | }
|
41 | }
|
42 |
|
43 | clearSearchCache() {
|
44 | if (this.searchCache) {
|
45 | this.searchCache.clear();
|
46 | }
|
47 | if (this.searchSyncCache) {
|
48 | this.searchSyncCache.clear();
|
49 | }
|
50 | }
|
51 |
|
52 | clearCaches() {
|
53 | this.clearLoadCache();
|
54 | this.clearSearchCache();
|
55 | }
|
56 |
|
57 | validateConfig() {
|
58 | const config = this.config;
|
59 |
|
60 | config.searchPlaces.forEach(place => {
|
61 | const loaderKey = path.extname(place) || 'noExt';
|
62 | const loader = config.loaders[loaderKey];
|
63 | if (!loader) {
|
64 | throw new Error(
|
65 | `No loader specified for ${getExtensionDescription(
|
66 | place
|
67 | )}, so searchPlaces item "${place}" is invalid`
|
68 | );
|
69 | }
|
70 | });
|
71 | }
|
72 |
|
73 | search(searchFrom ) {
|
74 | searchFrom = searchFrom || process.cwd();
|
75 | return getDirectory(searchFrom).then(dir => {
|
76 | return this.searchFromDirectory(dir);
|
77 | });
|
78 | }
|
79 |
|
80 | searchFromDirectory(dir ) {
|
81 | const absoluteDir = path.resolve(process.cwd(), dir);
|
82 | const run = () => {
|
83 | return this.searchDirectory(absoluteDir).then(result => {
|
84 | const nextDir = this.nextDirectoryToSearch(absoluteDir, result);
|
85 | if (nextDir) {
|
86 | return this.searchFromDirectory(nextDir);
|
87 | }
|
88 | return this.config.transform(result);
|
89 | });
|
90 | };
|
91 |
|
92 | if (this.searchCache) {
|
93 | return cacheWrapper(this.searchCache, absoluteDir, run);
|
94 | }
|
95 | return run();
|
96 | }
|
97 |
|
98 | searchSync(searchFrom ) {
|
99 | searchFrom = searchFrom || process.cwd();
|
100 | const dir = getDirectory.sync(searchFrom);
|
101 | return this.searchFromDirectorySync(dir);
|
102 | }
|
103 |
|
104 | searchFromDirectorySync(dir ) {
|
105 | const absoluteDir = path.resolve(process.cwd(), dir);
|
106 | const run = () => {
|
107 | const result = this.searchDirectorySync(absoluteDir);
|
108 | const nextDir = this.nextDirectoryToSearch(absoluteDir, result);
|
109 | if (nextDir) {
|
110 | return this.searchFromDirectorySync(nextDir);
|
111 | }
|
112 | return this.config.transform(result);
|
113 | };
|
114 |
|
115 | if (this.searchSyncCache) {
|
116 | return cacheWrapper(this.searchSyncCache, absoluteDir, run);
|
117 | }
|
118 | return run();
|
119 | }
|
120 |
|
121 | searchDirectory(dir ) {
|
122 | return this.config.searchPlaces.reduce((prevResultPromise, place) => {
|
123 | return prevResultPromise.then(prevResult => {
|
124 | if (this.shouldSearchStopWithResult(prevResult)) {
|
125 | return prevResult;
|
126 | }
|
127 | return this.loadSearchPlace(dir, place);
|
128 | });
|
129 | }, Promise.resolve(null));
|
130 | }
|
131 |
|
132 | searchDirectorySync(dir ) {
|
133 | let result = null;
|
134 | for (const place of this.config.searchPlaces) {
|
135 | result = this.loadSearchPlaceSync(dir, place);
|
136 | if (this.shouldSearchStopWithResult(result)) break;
|
137 | }
|
138 | return result;
|
139 | }
|
140 |
|
141 | shouldSearchStopWithResult(result ) {
|
142 | if (result === null) return false;
|
143 | if (result.isEmpty && this.config.ignoreEmptySearchPlaces) return false;
|
144 | return true;
|
145 | }
|
146 |
|
147 | loadSearchPlace(dir , place ) {
|
148 | const filepath = path.join(dir, place);
|
149 | return readFile(filepath).then(content => {
|
150 | return this.createCosmiconfigResult(filepath, content);
|
151 | });
|
152 | }
|
153 |
|
154 | loadSearchPlaceSync(dir , place ) {
|
155 | const filepath = path.join(dir, place);
|
156 | const content = readFile.sync(filepath);
|
157 | return this.createCosmiconfigResultSync(filepath, content);
|
158 | }
|
159 |
|
160 | nextDirectoryToSearch(
|
161 | currentDir ,
|
162 | currentResult
|
163 | ) {
|
164 | if (this.shouldSearchStopWithResult(currentResult)) {
|
165 | return null;
|
166 | }
|
167 | const nextDir = nextDirUp(currentDir);
|
168 | if (nextDir === currentDir || currentDir === this.config.stopDir) {
|
169 | return null;
|
170 | }
|
171 | return nextDir;
|
172 | }
|
173 |
|
174 | loadPackageProp(filepath , content ) {
|
175 | const parsedContent = loaders.loadJson(filepath, content);
|
176 | const packagePropValue = parsedContent[this.config.packageProp];
|
177 | return packagePropValue || null;
|
178 | }
|
179 |
|
180 | getLoaderEntryForFile(filepath ) {
|
181 | if (path.basename(filepath) === 'package.json') {
|
182 | const loader = this.loadPackageProp.bind(this);
|
183 | return { sync: loader, async: loader };
|
184 | }
|
185 |
|
186 | const loaderKey = path.extname(filepath) || 'noExt';
|
187 | return this.config.loaders[loaderKey];
|
188 | }
|
189 |
|
190 | getSyncLoaderForFile(filepath ) {
|
191 | const entry = this.getLoaderEntryForFile(filepath);
|
192 | if (!entry.sync) {
|
193 | throw new Error(
|
194 | `No sync loader specified for ${getExtensionDescription(filepath)}`
|
195 | );
|
196 | }
|
197 | return entry.sync;
|
198 | }
|
199 |
|
200 | getAsyncLoaderForFile(filepath ) {
|
201 | const entry = this.getLoaderEntryForFile(filepath);
|
202 | const loader = entry.async || entry.sync;
|
203 | if (!loader) {
|
204 | throw new Error(
|
205 | `No async loader specified for ${getExtensionDescription(filepath)}`
|
206 | );
|
207 | }
|
208 | return loader;
|
209 | }
|
210 |
|
211 | loadFileContent(
|
212 | mode ,
|
213 | filepath ,
|
214 | content
|
215 | ) {
|
216 | if (content === null) {
|
217 | return null;
|
218 | }
|
219 | if (content.trim() === '') {
|
220 | return undefined;
|
221 | }
|
222 | const loader =
|
223 | mode === MODE_SYNC
|
224 | ? this.getSyncLoaderForFile(filepath)
|
225 | : this.getAsyncLoaderForFile(filepath);
|
226 | const loadedContent = loader(filepath, content);
|
227 |
|
228 | if (mode === MODE_SYNC && loadedContent instanceof Promise) {
|
229 | throw new Error(
|
230 | `The sync loader for "${path.basename(
|
231 | filepath
|
232 | )}" returned a Promise. Sync loaders need to be synchronous.`
|
233 | );
|
234 | }
|
235 |
|
236 | return loadedContent;
|
237 | }
|
238 |
|
239 | loadedContentToCosmiconfigResult(
|
240 | filepath ,
|
241 | loadedContent
|
242 | ) {
|
243 | if (loadedContent === null) {
|
244 | return null;
|
245 | }
|
246 | if (loadedContent === undefined) {
|
247 | return { filepath, config: undefined, isEmpty: true };
|
248 | }
|
249 | return { config: loadedContent, filepath };
|
250 | }
|
251 |
|
252 | createCosmiconfigResult(
|
253 | filepath ,
|
254 | content
|
255 | ) {
|
256 | return Promise.resolve()
|
257 | .then(() => {
|
258 | return this.loadFileContent('async', filepath, content);
|
259 | })
|
260 | .then(loaderResult => {
|
261 | return this.loadedContentToCosmiconfigResult(filepath, loaderResult);
|
262 | });
|
263 | }
|
264 |
|
265 | createCosmiconfigResultSync(
|
266 | filepath ,
|
267 | content
|
268 | ) {
|
269 | const loaderResult = this.loadFileContent('sync', filepath, content);
|
270 | return this.loadedContentToCosmiconfigResult(filepath, loaderResult);
|
271 | }
|
272 |
|
273 | validateFilePath(filepath ) {
|
274 | if (!filepath) {
|
275 | throw new Error('load and loadSync must be pass a non-empty string');
|
276 | }
|
277 | }
|
278 |
|
279 | load(filepath ) {
|
280 | return Promise.resolve().then(() => {
|
281 | this.validateFilePath(filepath);
|
282 | const absoluteFilePath = path.resolve(process.cwd(), filepath);
|
283 | return cacheWrapper(this.loadCache, absoluteFilePath, () => {
|
284 | return readFile(absoluteFilePath, { throwNotFound: true })
|
285 | .then(content => {
|
286 | return this.createCosmiconfigResult(filepath, content);
|
287 | })
|
288 | .then(this.config.transform);
|
289 | });
|
290 | });
|
291 | }
|
292 |
|
293 | loadSync(filepath ) {
|
294 | this.validateFilePath(filepath);
|
295 | const absoluteFilePath = path.resolve(process.cwd(), filepath);
|
296 | return cacheWrapper(this.loadSyncCache, absoluteFilePath, () => {
|
297 | const content = readFile.sync(absoluteFilePath, { throwNotFound: true });
|
298 | const result = this.createCosmiconfigResultSync(filepath, content);
|
299 | return this.config.transform(result);
|
300 | });
|
301 | }
|
302 | }
|
303 |
|
304 | module.exports = function createExplorer(options ) {
|
305 | const explorer = new Explorer(options);
|
306 |
|
307 | return {
|
308 | search: explorer.search.bind(explorer),
|
309 | searchSync: explorer.searchSync.bind(explorer),
|
310 | load: explorer.load.bind(explorer),
|
311 | loadSync: explorer.loadSync.bind(explorer),
|
312 | clearLoadCache: explorer.clearLoadCache.bind(explorer),
|
313 | clearSearchCache: explorer.clearSearchCache.bind(explorer),
|
314 | clearCaches: explorer.clearCaches.bind(explorer),
|
315 | };
|
316 | };
|
317 |
|
318 | function nextDirUp(dir ) {
|
319 | return path.dirname(dir);
|
320 | }
|
321 |
|
322 | function getExtensionDescription(filepath ) {
|
323 | const ext = path.extname(filepath);
|
324 | return ext ? `extension "${ext}"` : 'files without extensions';
|
325 | }
|