diff --git a/codemods/qs/index.js b/codemods/qs/index.js new file mode 100644 index 0000000..6cf8ebc --- /dev/null +++ b/codemods/qs/index.js @@ -0,0 +1,295 @@ +import { ts } from '@ast-grep/napi'; + +const qsLikeOptions = { + nesting: true, + nestingSyntax: 'js', + arrayRepeat: true, + arrayRepeatSyntax: 'bracket', +}; +const qsLikeOptionsStr = JSON.stringify(qsLikeOptions); + +/** + * @typedef {import('../../types.js').Codemod} Codemod + * @typedef {import('../../types.js').CodemodOptions} CodemodOptions + * @typedef {import('@ast-grep/napi').SgNode} SgNode + */ + +/** + * @param {SgNode} obj + * @return {Record} + */ +function parseOptions(obj) { + /** @type {Record} */ + const result = {}; + + for (const child of obj.children()) { + const key = child.field('key'); + const val = child.field('value'); + + if (key && val) { + result[key.text()] = val; + } + } + + return result; +} + +/** @typedef {Record | ((value: SgNode) => Record|null) | null} ReplacementOptions */ +/** @typedef {({kind: string; options: ReplacementOptions})} Replacement */ +/** @typedef {({replacements: Replacement[]})} Replacer */ +/** @type {Record} */ +const replacements = { + indices: { + replacements: [ + { + kind: 'false', + options: { + nestingSyntax: 'dot', + arrayRepeatSyntax: 'repeat', + }, + }, + { + kind: 'true', + options: { + nestingSyntax: 'js', + }, + }, + ], + }, + arrayFormat: { + replacements: [ + { + kind: 'string', + options: (value) => { + const formatStr = value.child(1)?.text(); + if (formatStr === 'repeat') { + return { arrayRepeatSyntax: 'repeat' }; + } else if (formatStr === 'indices') { + return { arrayRepeat: false }; + } + return { arrayRepeatSyntax: 'bracket' }; + }, + }, + ], + }, + allowDots: { + replacements: [ + { + kind: 'true', + options: { + nestingSyntax: 'js', + }, + }, + { + kind: 'false', + options: { + nestingSyntax: 'index', + }, + }, + ], + }, + parseArrays: { + replacements: [ + { + kind: 'false', + options: { + arrayRepeat: false, + }, + }, + { + kind: 'true', + options: { + arrayRepeat: true, + }, + }, + ], + }, + delimiter: { + replacements: [ + { + kind: 'string', + options: (value) => { + const delimiter = value.child(1)?.text(); + if (delimiter) { + return { delimiter }; + } + console.warn( + `Warning: encountered a delimiter we could not ` + + `transform. It will be dropped, so may need additional fixes ` + + `after this codemod executes`, + ); + return null; + }, + }, + ], + }, +}; + +/** + * @param {CodemodOptions} [options] + * @returns {Codemod} + */ +export default function (options) { + return { + name: 'qs', + transform: ({ file }) => { + const ast = ts.parse(file.source); + const root = ast.root(); + const imports = root.findAll({ + rule: { + pattern: { + context: "import $NAME from 'qs'", + strictness: 'relaxed', + }, + }, + }); + const requires = root.findAll({ + rule: { + pattern: { + context: "require('qs')", + strictness: 'relaxed', + }, + }, + }); + let importName = 'qs'; + const edits = []; + + for (const imp of imports) { + const source = imp.field('source'); + + if (!source) { + continue; + } + + const quoteType = source.text().startsWith("'") ? "'" : '"'; + const nameMatch = imp.getMatch('NAME'); + + if (nameMatch) { + importName = nameMatch.text(); + edits.push(nameMatch.replace('pq')); + } + + edits.push(source.replace(`${quoteType}picoquery${quoteType}`)); + } + + for (const req of requires) { + const args = req.field('arguments'); + const firstArg = args?.child(1); + const quoteType = firstArg?.text().startsWith('"') ? '"' : "'"; + + edits.push(req.replace(`require(${quoteType}picoquery${quoteType})`)); + + const parent = req.parent(); + + if (parent && parent.kind() === 'variable_declarator') { + const name = parent.field('name'); + if (name) { + importName = name.text(); + edits.push(name.replace('pq')); + } + } + } + + const expressions = root.findAll({ + rule: { + pattern: `${importName}.$METHOD($$$ARGS)`, + }, + }); + + for (const expr of expressions) { + const method = expr.getMatch('METHOD'); + const args = expr.getMultipleMatches('ARGS'); + const methodText = method?.text(); + const func = expr.field('function')?.field('object'); + + if ( + !func || + !method || + (methodText !== 'parse' && methodText !== 'stringify') + ) { + continue; + } + + let decodeResult = false; + + if (args.length === 1) { + edits.push(args[0].replace(`${args[0].text()}, ${qsLikeOptionsStr}`)); + } else if (args.length > 2) { + const opts = parseOptions(args[2]); + + /** @type {Record} */ + const newOptions = { ...qsLikeOptions }; + + for (const [key, val] of Object.entries(opts)) { + // Special case for the `encode` option if it is `false`, as we + // need to wrap the entire result with `decodeURIComponent` + if (key === 'encode' && val.kind() === 'false') { + decodeResult = true; + continue; + } + + const replacer = replacements[key]; + + if (!replacer) { + console.warn( + `Warning: encountered an unknown option. ` + + `The option ("${key}") will be dropped, so may need ` + + `additional fixes after this codemod executes.`, + ); + continue; + } + + let foundReplacement = false; + for (const replacement of replacer.replacements) { + if (replacement.kind === val.kind()) { + const replacementOpts = + typeof replacement.options === 'function' + ? replacement.options(val) + : replacement.options; + foundReplacement = true; + if (replacementOpts) { + for (const optKey in replacementOpts) { + newOptions[optKey] = replacementOpts[optKey]; + } + } + } + } + + if (!foundReplacement) { + console.warn( + `Warning: encountered an option with a value we could not parse. ` + + `The option ("${key}") has a computed value or an unexpected ` + + `type. It will be dropped, so may need additional fixes ` + + `after this codemod executes.`, + ); + } + } + + edits.push(args[2].replace(JSON.stringify(newOptions))); + } + + if (decodeResult) { + console.warn( + `Warning: the "encode: false" option will be ` + + `replaced by a call to decodeURIComponent`, + ); + edits.push(func.replace('decodeURIComponent(pq')); + + const argsChildren = expr.field('arguments')?.children(); + + if (argsChildren) { + const lastArgsChild = argsChildren[argsChildren.length - 1]; + + if (lastArgsChild.kind() === ')') { + edits.push(lastArgsChild.replace(`${lastArgsChild.text()})`)); + } + } + } else { + edits.push(func.replace('pq')); + } + } + + return root.commitEdits(edits); + }, + }; +} diff --git a/index.js b/index.js index d2ad16d..a9edf82 100644 --- a/index.js +++ b/index.js @@ -121,6 +121,7 @@ import parseint from './codemods/parseint/index.js'; import promiseAllsettled from './codemods/promise.allsettled/index.js'; import promiseAny from './codemods/promise.any/index.js'; import promisePrototypeFinally from './codemods/promise.prototype.finally/index.js'; +import qs from './codemods/qs/index.js'; import reflectGetprototypeof from './codemods/reflect.getprototypeof/index.js'; import reflectOwnkeys from './codemods/reflect.ownkeys/index.js'; import regexpPrototypeFlags from './codemods/regexp.prototype.flags/index.js'; @@ -285,6 +286,7 @@ export const codemods = { "promise.allsettled": promiseAllsettled, "promise.any": promiseAny, "promise.prototype.finally": promisePrototypeFinally, + "qs": qs, "reflect.getprototypeof": reflectGetprototypeof, "reflect.ownkeys": reflectOwnkeys, "regexp.prototype.flags": regexpPrototypeFlags, diff --git a/test/fixtures/qs/basic/after.js b/test/fixtures/qs/basic/after.js new file mode 100644 index 0000000..8832aff --- /dev/null +++ b/test/fixtures/qs/basic/after.js @@ -0,0 +1,49 @@ +import pq from 'picoquery'; + +const obj = {foo: 'bar'}; +const query = 'foo=bar'; + +// indices: false +pq.stringify(obj, {"nesting":true,"nestingSyntax":"dot","arrayRepeat":true,"arrayRepeatSyntax":"repeat"}); + +// defaults +pq.stringify(obj, {"nesting":true,"nestingSyntax":"js","arrayRepeat":true,"arrayRepeatSyntax":"bracket"}); + +// arrayFormat: repeat +pq.stringify(obj, {"nesting":true,"nestingSyntax":"js","arrayRepeat":true,"arrayRepeatSyntax":"repeat"}); + +// arrayFormat: indices +pq.stringify(obj, {"nesting":true,"nestingSyntax":"js","arrayRepeat":false,"arrayRepeatSyntax":"bracket"}); + +// arrayFormat: brackets +pq.stringify(obj, {"nesting":true,"nestingSyntax":"js","arrayRepeat":true,"arrayRepeatSyntax":"bracket"}); + +// arrayFormat: nonsense defaults to bracket +pq.stringify(obj, {"nesting":true,"nestingSyntax":"js","arrayRepeat":true,"arrayRepeatSyntax":"bracket"}); + +// encode: false +decodeURIComponent(pq.stringify(obj, {"nesting":true,"nestingSyntax":"js","arrayRepeat":true,"arrayRepeatSyntax":"bracket"})); + +// indices: false +pq.parse(query, {"nesting":true,"nestingSyntax":"dot","arrayRepeat":true,"arrayRepeatSyntax":"repeat"}); + +// indices: true +pq.parse(query, {"nesting":true,"nestingSyntax":"js","arrayRepeat":true,"arrayRepeatSyntax":"bracket"}); + +// defaults +pq.parse(query, {"nesting":true,"nestingSyntax":"js","arrayRepeat":true,"arrayRepeatSyntax":"bracket"}); + +// delimiter +pq.parse('a=foo;b=bar', {"nesting":true,"nestingSyntax":"js","arrayRepeat":true,"arrayRepeatSyntax":"bracket","delimiter":";"}); + +// allowDots: true +pq.parse('a.b=c', {"nesting":true,"nestingSyntax":"js","arrayRepeat":true,"arrayRepeatSyntax":"bracket"}); + +// allowDots: false +pq.parse('a.b=c', {"nesting":true,"nestingSyntax":"index","arrayRepeat":true,"arrayRepeatSyntax":"bracket"}); + +// parseArrays: false +pq.parse('a[]=1&a[]=2', {"nesting":true,"nestingSyntax":"js","arrayRepeat":false,"arrayRepeatSyntax":"bracket"}); + +// parseArrays: true +pq.parse('a[]=1&a[]=2', {"nesting":true,"nestingSyntax":"js","arrayRepeat":true,"arrayRepeatSyntax":"bracket"}); diff --git a/test/fixtures/qs/basic/before.js b/test/fixtures/qs/basic/before.js new file mode 100644 index 0000000..8bcfddd --- /dev/null +++ b/test/fixtures/qs/basic/before.js @@ -0,0 +1,49 @@ +import qs from 'qs'; + +const obj = {foo: 'bar'}; +const query = 'foo=bar'; + +// indices: false +qs.stringify(obj, {indices: false}); + +// defaults +qs.stringify(obj); + +// arrayFormat: repeat +qs.stringify(obj, {arrayFormat: 'repeat'}); + +// arrayFormat: indices +qs.stringify(obj, {arrayFormat: 'indices'}); + +// arrayFormat: brackets +qs.stringify(obj, {arrayFormat: 'brackets'}); + +// arrayFormat: nonsense defaults to bracket +qs.stringify(obj, {arrayFormat: 'absolute gibberish'}); + +// encode: false +qs.stringify(obj, {encode: false}); + +// indices: false +qs.parse(query, { indices: false }); + +// indices: true +qs.parse(query, { indices: true }); + +// defaults +qs.parse(query); + +// delimiter +qs.parse('a=foo;b=bar', {delimiter: ';'}); + +// allowDots: true +qs.parse('a.b=c', {allowDots: true}); + +// allowDots: false +qs.parse('a.b=c', {allowDots: false}); + +// parseArrays: false +qs.parse('a[]=1&a[]=2', {parseArrays: false}); + +// parseArrays: true +qs.parse('a[]=1&a[]=2', {parseArrays: true}); diff --git a/test/fixtures/qs/basic/result.js b/test/fixtures/qs/basic/result.js new file mode 100644 index 0000000..8832aff --- /dev/null +++ b/test/fixtures/qs/basic/result.js @@ -0,0 +1,49 @@ +import pq from 'picoquery'; + +const obj = {foo: 'bar'}; +const query = 'foo=bar'; + +// indices: false +pq.stringify(obj, {"nesting":true,"nestingSyntax":"dot","arrayRepeat":true,"arrayRepeatSyntax":"repeat"}); + +// defaults +pq.stringify(obj, {"nesting":true,"nestingSyntax":"js","arrayRepeat":true,"arrayRepeatSyntax":"bracket"}); + +// arrayFormat: repeat +pq.stringify(obj, {"nesting":true,"nestingSyntax":"js","arrayRepeat":true,"arrayRepeatSyntax":"repeat"}); + +// arrayFormat: indices +pq.stringify(obj, {"nesting":true,"nestingSyntax":"js","arrayRepeat":false,"arrayRepeatSyntax":"bracket"}); + +// arrayFormat: brackets +pq.stringify(obj, {"nesting":true,"nestingSyntax":"js","arrayRepeat":true,"arrayRepeatSyntax":"bracket"}); + +// arrayFormat: nonsense defaults to bracket +pq.stringify(obj, {"nesting":true,"nestingSyntax":"js","arrayRepeat":true,"arrayRepeatSyntax":"bracket"}); + +// encode: false +decodeURIComponent(pq.stringify(obj, {"nesting":true,"nestingSyntax":"js","arrayRepeat":true,"arrayRepeatSyntax":"bracket"})); + +// indices: false +pq.parse(query, {"nesting":true,"nestingSyntax":"dot","arrayRepeat":true,"arrayRepeatSyntax":"repeat"}); + +// indices: true +pq.parse(query, {"nesting":true,"nestingSyntax":"js","arrayRepeat":true,"arrayRepeatSyntax":"bracket"}); + +// defaults +pq.parse(query, {"nesting":true,"nestingSyntax":"js","arrayRepeat":true,"arrayRepeatSyntax":"bracket"}); + +// delimiter +pq.parse('a=foo;b=bar', {"nesting":true,"nestingSyntax":"js","arrayRepeat":true,"arrayRepeatSyntax":"bracket","delimiter":";"}); + +// allowDots: true +pq.parse('a.b=c', {"nesting":true,"nestingSyntax":"js","arrayRepeat":true,"arrayRepeatSyntax":"bracket"}); + +// allowDots: false +pq.parse('a.b=c', {"nesting":true,"nestingSyntax":"index","arrayRepeat":true,"arrayRepeatSyntax":"bracket"}); + +// parseArrays: false +pq.parse('a[]=1&a[]=2', {"nesting":true,"nestingSyntax":"js","arrayRepeat":false,"arrayRepeatSyntax":"bracket"}); + +// parseArrays: true +pq.parse('a[]=1&a[]=2', {"nesting":true,"nestingSyntax":"js","arrayRepeat":true,"arrayRepeatSyntax":"bracket"});