Skip to content

Commit

Permalink
Improve file path normalization
Browse files Browse the repository at this point in the history
  • Loading branch information
ehmicky committed Jan 16, 2022
1 parent 12e3d96 commit 5102c63
Show file tree
Hide file tree
Showing 8 changed files with 83 additions and 143 deletions.
17 changes: 0 additions & 17 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@
"p-map": "^5.3.0",
"p-map-series": "^3.0.0",
"p-props": "^5.0.0",
"p-reduce": "^3.0.0",
"path-exists": "^5.0.0",
"path-type": "^5.0.0",
"precise-now": "^0.3.1",
Expand Down
20 changes: 11 additions & 9 deletions src/config/check.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import mapObj from 'map-obj'
import { UserError } from '../error/main.js'

// Configuration validation helper functions
export const checkArrayItems = function (checkers, value, name) {
// eslint-disable-next-line max-params
export const checkArrayItems = function (checkers, value, name, ...args) {
checkArray(value, name)
return value.map((item, index) =>
applyCheckers(checkers, item, getIndexName(name, index, value)),
return value.flatMap((item, index) =>
applyCheckers(checkers, item, getIndexName(name, index, value), ...args),
)
}

Expand All @@ -18,23 +19,24 @@ const getIndexName = function (name, index, value) {
return value.length === 1 ? name : `${name}[${index}]`
}

export const checkObjectProps = function (checkers, value, name) {
// eslint-disable-next-line max-params
export const checkObjectProps = function (checkers, value, name, ...args) {
checkObject(value, name)
return mapObj(value, (childName, childValue) => [
childName,
applyCheckers(checkers, childValue, `${name}.${childName}`),
applyCheckers(checkers, childValue, `${name}.${childName}`, ...args),
])
}

const applyCheckers = function (checkers, value, name) {
const applyCheckers = function (checkers, value, ...args) {
return checkers.reduce(
(valueA, checker) => applyChecker(checker, valueA, name),
(valueA, checker) => applyChecker(checker, valueA, ...args),
value,
)
}

const applyChecker = function (checker, value, name) {
const newValue = checker(value, name)
const applyChecker = function (checker, value, ...args) {
const newValue = checker(value, ...args)
return newValue === undefined ? value : newValue
}

Expand Down
8 changes: 3 additions & 5 deletions src/config/main.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { addDefaultConfig } from './default.js'
import { loadConfig } from './load/main.js'
import { normalizeConfig } from './normalize.js'
import { normalizeConfigPaths } from './path.js'
import { pickCommandConfig } from './pick.js'
import { addPlugins } from './plugin/add.js'

Expand All @@ -10,8 +9,7 @@ export const getConfig = async function (command, configFlags = {}) {
const { config, configInfos } = await loadConfig(configFlags)
const configA = addDefaultConfig(config, command)
const configB = pickCommandConfig(configA, command)
const configC = normalizeConfig(configB)
const configD = await normalizeConfigPaths(configC, configInfos)
const configE = await addPlugins(configD, command)
return configE
const configC = normalizeConfig(configB, configInfos)
const configD = await addPlugins(configC, command)
return configD
}
43 changes: 26 additions & 17 deletions src/config/normalize.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import mapObj from 'map-obj'
import { normalizeLimit } from '../history/compare/normalize.js'
import { normalizeDelta } from '../history/delta/normalize.js'
import { validateMerge } from '../history/merge/id.js'
import { isOutputPath } from '../report/output.js'
import { normalizePrecision } from '../run/precision.js'
import { condition } from '../utils/functional.js'

import {
checkBoolean,
Expand All @@ -16,65 +18,72 @@ import {
checkDefinedString,
checkJson,
} from './check.js'
import { normalizeConfigPath, normalizeConfigGlob } from './path.js'
import { validateConfigSelector, isConfigSelector } from './select/normalize.js'

// Normalize configuration shape and do custom validation
export const normalizeConfig = function (config) {
return mapObj(config, normalizePropEntry)
}

const normalizePropEntry = function (propName, value) {
const valueA = normalizePropDeep(value, propName)
return [propName, valueA]
export const normalizeConfig = function (config, configInfos) {
// eslint-disable-next-line fp/no-mutating-methods
const configInfosA = [...configInfos].reverse()
return mapObj(config, (propName, value) => [
propName,
normalizePropDeep(value, propName, configInfosA),
])
}

// If a configuration property uses selectors or variations, normalization must
// be applied recursively.
const normalizePropDeep = function (value, propName) {
const normalizePropDeep = function (value, propName, configInfos) {
if (!isDeepProp(value, propName)) {
return normalizePropValue(value, propName, propName)
return normalizePropValue({ value, propName, name: propName, configInfos })
}

validateConfigSelector(value, propName)

return mapObj(value, (selector, childValue) => [
selector,
normalizePropValue(childValue, propName, `${propName}.${selector}`),
normalizePropValue({
value: childValue,
propName,
name: `${propName}.${selector}`,
configInfos,
}),
])
}

const isDeepProp = function (configValue, propName) {
return isConfigSelector(configValue, propName)
}

const normalizePropValue = function (value, propName, name) {
const normalizePropValue = function ({ value, propName, name, configInfos }) {
const normalizers = NORMALIZERS[propName]

if (normalizers === undefined) {
return value
}

return normalizers.reduce(
(valueA, normalizer) => applyNormalizer(valueA, name, normalizer),
(valueA, normalizer) =>
applyNormalizer({ value: valueA, name, normalizer, configInfos }),
value,
)
}

const applyNormalizer = function (value, name, normalizer) {
const newValue = normalizer(value, name)
const applyNormalizer = function ({ value, name, normalizer, configInfos }) {
const newValue = normalizer(value, name, configInfos)
return newValue === undefined ? value : newValue
}

// TODO: missing `reporterConfig`, `runnerConfig`
const NORMALIZERS = {
colors: [checkBoolean],
cwd: [checkDefinedString],
cwd: [checkDefinedString, normalizeConfigPath],
delta: [normalizeDelta],
force: [checkBoolean],
inputs: [checkObjectProps.bind(undefined, [checkJson])],
limit: [checkInteger, normalizeLimit],
merge: [checkDefinedString, validateMerge],
output: [checkDefinedString],
output: [checkDefinedString, condition(normalizeConfigPath, isOutputPath)],
outliers: [checkBoolean],
precision: [checkInteger, normalizePrecision],
quiet: [checkBoolean],
Expand All @@ -101,7 +110,7 @@ const NORMALIZERS = {
system: [checkObjectProps.bind(undefined, [checkDefinedString])],
tasks: [
normalizeOptionalArray,
checkArrayItems.bind(undefined, [checkDefinedString]),
checkArrayItems.bind(undefined, [checkDefinedString, normalizeConfigGlob]),
],
titles: [checkObjectProps.bind(undefined, [checkDefinedString])],
}
121 changes: 27 additions & 94 deletions src/config/path.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,111 +3,27 @@ import { resolve, basename } from 'path'
import dotProp from 'dot-prop'
import fastGlob from 'fast-glob'
import { isNotJunk } from 'junk'
import pReduce from 'p-reduce'

// Normalize all configuration file paths
export const normalizeConfigPaths = async function (config, configInfos) {
// eslint-disable-next-line fp/no-mutating-methods
const configInfosA = [...configInfos].reverse()
return await pReduce(
PATH_CONFIG_PROPS,
reduceConfigPath.bind(undefined, configInfosA),
config,
)
}

// List of all configuration properties that are file paths or globbing patterns
const PATH_CONFIG_PROPS = [
{ propName: 'cwd', globbing: false },
{
propName: 'output',
globbing: false,
isPath: (value) => !OUTPUT_SPECIAL_VALUES.has(value),
},
{ propName: 'tasks', globbing: true },
]

const OUTPUT_SPECIAL_VALUES = new Set(['stdout', 'external'])

const reduceConfigPath = async function (
configInfos,
config,
{ propName, globbing, isPath },
) {
const value = dotProp.get(config, propName)

if (isNotPath(value, isPath)) {
return config
}

const valueA = await normalizeConfigPath({
value,
propName,
globbing,
configInfos,
})
return dotProp.set(config, propName, valueA)
}

// Some properties like `output` are not always file paths
const isNotPath = function (value, isPath) {
return value === undefined || (isPath !== undefined && !isPath(value))
}

const normalizeConfigPath = async function ({
value,
propName,
globbing,
configInfos,
}) {
const base = getBase(configInfos, propName)

if (globbing) {
return await resolveGlobbing(value, base)
}

if (!Array.isArray(value)) {
return setAbsolutePath(base, value)
}

const filePaths = value.map((item) => setAbsolutePath(base, item))
return [...new Set(filePaths)]
}

// Properties assigned as default values do not have corresponding `configInfos`
// - By default, they use the top-level config file's directory as base
// - If none, they use process.cwd() instead
const getBase = function (configInfos, propName) {
const configInfo = configInfos.find(({ configContents }) =>
dotProp.has(configContents, propName),
)

if (configInfo !== undefined) {
return configInfo.base
}

const [, topLevelConfigInfo] = configInfos

if (topLevelConfigInfo !== undefined) {
return topLevelConfigInfo.base
}

return '.'
// Resolve configuration relative file paths to absolute paths.
export const normalizeConfigPath = function (value, name, configInfos) {
const base = getBase(configInfos, name)
return resolve(base, value)
}

// Resolve configuration properties that are globbing patterns.
// Also resolve to absolute file paths.
// Remove duplicates and temporary files.
const resolveGlobbing = async function (pattern, base) {
const filePaths = await fastGlob(pattern, {
// TODO: use asynchronous code instead.
export const normalizeConfigGlob = function (value, name, configInfos) {
const base = getBase(configInfos, name)
const filePaths = fastGlob.sync(value, {
cwd: base,
absolute: true,
unique: true,
})
return filePaths.filter((filePath) => isNotJunk(basename(filePath)))
}

// Resolve configuration relative file paths to absolute paths.
// When resolving configuration relative file paths:
// - The CLI and programmatic flags always use the current directory.
// - This includes flags' default values, including `config` and `tasks`
Expand All @@ -132,6 +48,23 @@ const resolveGlobbing = async function (pattern, base) {
// repository could be re-used for different cwd
// - user can opt-out of that behavior by using absolute file paths, for
// example using the current file's path (e.g. `import.meta.url`)
const setAbsolutePath = function (base, filePath) {
return resolve(base, filePath)
// Properties assigned as default values do not have corresponding `configInfos`
// - By default, they use the top-level config file's directory as base
// - If none, they use process.cwd() instead
const getBase = function (configInfos, propName) {
const configInfo = configInfos.find(({ configContents }) =>
dotProp.has(configContents, propName),
)

if (configInfo !== undefined) {
return configInfo.base
}

const [, topLevelConfigInfo] = configInfos

if (topLevelConfigInfo !== undefined) {
return topLevelConfigInfo.base
}

return '.'
}
7 changes: 7 additions & 0 deletions src/report/output.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ import { UserError } from '../error/main.js'
import { detectInsert, insertContents } from './insert.js'
import { printToStdout } from './tty.js'

// `output` path is normalized, but some of its values are not file paths
export const isOutputPath = function (output) {
return !OUTPUT_SPECIAL_VALUES.has(output)
}

const OUTPUT_SPECIAL_VALUES = new Set(['stdout', 'external'])

// Print result to file or to terminal based on the `output` configuration
// property.
// If the file contains the spyd-start and spyd-end comments, the content is
Expand Down
9 changes: 9 additions & 0 deletions src/utils/functional.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Apply `callFunc(...)` but only if `conditionFunc(...)` returns `true`.
// Otherwise, returns the first argument as is.
export const condition = function (callFunc, conditionFunc) {
return conditionCall.bind(undefined, callFunc, conditionFunc)
}

const conditionCall = function (callFunc, conditionFunc, ...args) {
return conditionFunc(...args) ? callFunc(...args) : args[0]
}

0 comments on commit 5102c63

Please sign in to comment.