From 944d9fc7411449e2707918b938a30029e79db722 Mon Sep 17 00:00:00 2001 From: 43081j <43081j@users.noreply.github.com> Date: Fri, 2 Aug 2024 15:07:06 +0100 Subject: [PATCH] feat: add qs codemod Adds a codemod for migrating from `qs` to `picoquery`. Some options qs offers are not available. Currently, this mod will not warn on those but should in future. --- codemods/qs/index.js | 167 +++++++++++++++++++++++++++++++ index.js | 2 + test/fixtures/qs/basic/after.js | 34 +++++++ test/fixtures/qs/basic/before.js | 34 +++++++ test/fixtures/qs/basic/result.js | 34 +++++++ 5 files changed, 271 insertions(+) create mode 100644 codemods/qs/index.js create mode 100644 test/fixtures/qs/basic/after.js create mode 100644 test/fixtures/qs/basic/before.js create mode 100644 test/fixtures/qs/basic/result.js diff --git a/codemods/qs/index.js b/codemods/qs/index.js new file mode 100644 index 0000000..0f39230 --- /dev/null +++ b/codemods/qs/index.js @@ -0,0 +1,167 @@ +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 + */ +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; +} + +/** + * @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; + } + + edits.push(func.replace('pq')); + + 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 }; + + if (opts.indices && opts.indices.kind() === 'false') { + newOptions.nestingSyntax = 'dot'; + newOptions.arrayRepeatSyntax = 'repeat'; + } + + if (opts.arrayFormat && opts.arrayFormat.kind() === 'string') { + const arrayFormat = opts.arrayFormat.child(1)?.text(); + if (arrayFormat === 'repeat') { + newOptions.arrayRepeatSyntax = 'repeat'; + } else if (arrayFormat === 'indices') { + newOptions.arrayRepeat = false; + } + } + + if (opts.allowDots && opts.allowDots.kind() === 'true') { + newOptions.nestingSyntax = 'dot'; + } + + if (opts.parseArrays && opts.parseArrays.kind() === 'false') { + newOptions.arrayRepeat = false; + } + + if (opts.delimiter && opts.delimiter.kind() === 'string') { + const delimiter = opts.delimiter.child(1)?.text(); + if (delimiter) { + newOptions.delimiter = delimiter; + } + } + + edits.push(args[2].replace(JSON.stringify(newOptions))); + } + } + + return root.commitEdits(edits); + }, + }; +} diff --git a/index.js b/index.js index aa8285b..cc3adff 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'; @@ -275,6 +276,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..44544b5 --- /dev/null +++ b/test/fixtures/qs/basic/after.js @@ -0,0 +1,34 @@ +import pq from 'picoquery'; + +const obj = {foo: 'bar'}; +const query = 'foo=bar'; + +// encode: false, 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"}); + +// encode: false, indices: false +pq.parse(query, {"nesting":true,"nestingSyntax":"dot","arrayRepeat":true,"arrayRepeatSyntax":"repeat"}); + +// 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 +pq.parse('a.b=c', {"nesting":true,"nestingSyntax":"dot","arrayRepeat":true,"arrayRepeatSyntax":"bracket"}); + +// parseArrays: false +pq.parse('a[]=1&a[]=2', {"nesting":true,"nestingSyntax":"js","arrayRepeat":false,"arrayRepeatSyntax":"bracket"}); diff --git a/test/fixtures/qs/basic/before.js b/test/fixtures/qs/basic/before.js new file mode 100644 index 0000000..573fdfe --- /dev/null +++ b/test/fixtures/qs/basic/before.js @@ -0,0 +1,34 @@ +import qs from 'qs'; + +const obj = {foo: 'bar'}; +const query = 'foo=bar'; + +// encode: false, indices: false +qs.stringify(obj, { encode: false, 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'}); + +// encode: false, indices: false +qs.parse(query, { encode: false, indices: false }); + +// defaults +qs.parse(query); + +// delimiter +qs.parse('a=foo;b=bar', {delimiter: ';'}); + +// allowDots +qs.parse('a.b=c', {allowDots: true}); + +// parseArrays: false +qs.parse('a[]=1&a[]=2', {parseArrays: false}); diff --git a/test/fixtures/qs/basic/result.js b/test/fixtures/qs/basic/result.js new file mode 100644 index 0000000..44544b5 --- /dev/null +++ b/test/fixtures/qs/basic/result.js @@ -0,0 +1,34 @@ +import pq from 'picoquery'; + +const obj = {foo: 'bar'}; +const query = 'foo=bar'; + +// encode: false, 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"}); + +// encode: false, indices: false +pq.parse(query, {"nesting":true,"nestingSyntax":"dot","arrayRepeat":true,"arrayRepeatSyntax":"repeat"}); + +// 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 +pq.parse('a.b=c', {"nesting":true,"nestingSyntax":"dot","arrayRepeat":true,"arrayRepeatSyntax":"bracket"}); + +// parseArrays: false +pq.parse('a[]=1&a[]=2', {"nesting":true,"nestingSyntax":"js","arrayRepeat":false,"arrayRepeatSyntax":"bracket"});