Putnik (Chroatian) - passenger
Putnik is a code transformation engine built on top of πPutout. Instead of traversing the AST in memory with Babel, it writes the AST into a SQLite database, runs SQL-aware plugins against it, then reads the AST back and prints it with @putout/printer.
The key idea: SQL indexes beat Babel traverse at scale. A plugin that finds DebuggerStatement nodes queries one table with one index hit instead of visiting every node in the file.
npm i putnikimport {
putnik,
parse,
print,
} from 'putnik';
parse('src/index.js', 'const a = 1;');
const constPlugin = {
select: `
SELECT id, type, file, start_line, start_col
FROM VariableDeclaration
WHERE file = :file AND kind = 'const'
`,
report: `
SELECT 'Prefer let over const' AS message, start_line AS line, start_col AS col
FROM VariableDeclaration
WHERE file = :file AND kind = 'const'
`,
fix: `
UPDATE VariableDeclaration SET kind = 'let'
WHERE file = :file AND kind = 'const'
`,
};
// report mode β returns [code, places], does not mutate
const [code, places] = await putnik('src/index.js', [constPlugin]);
// places: [{message: 'Prefer let over const', line: 1, col: 0}]
// fix mode β mutates the DB, returns [newCode, places]
const [newCode] = await putnik('src/index.js', [constPlugin], {
fix: true,
});
console.log(newCode);
// let a = 1;Parses source with @putout/babel and writes every AST node into its typed table. Call once per file before run.
parse('src/index.js', 'const a = 1;');Each Babel node type gets its own table. Every row shares these base columns:
| column | type | description |
|---|---|---|
| id | INTEGER | generated by the database β never set manually |
| file | TEXT | source file path |
| parent_id | INTEGER | id of the parent node |
| parent_type | TEXT | type of the parent node |
| parent_field | TEXT | field name on parent (id, init, body, β¦) |
| start_line | INT | location |
| start_col | INT | location |
| end_line | INT | location |
| end_col | INT | location |
Type-specific columns: kind on VariableDeclaration, name on Identifier, value on StringLiteral and NumericLiteral. Boolean-like fields (async, generator, computed, etc.) are stored as INTEGER β 1 for true, 0 for false.
Report mode, does not mutate the DB:
const [code, places] = await putnik('src/index.js', source, {
plugins,
fix: false,
});fix mode, mutates the DB:
const [newCode, places] = await putnik('src/index.js', source, {
plugins,
fix: true,
});Returns [code, places] β same shape as putout. code is the transformed source in fix mode, the original source in report mode. places is an array of {message, line, col}.
The runner passes each row returned by @select as named parameters into @fix, so fix queries can reference :id, :type, :file, :parent_id, :parent_type, :parent_field, :start_line, :start_col, :end_line, :end_col, and any type-specific columns from the matched row.
Reads all nodes for file from the DB, assembles the AST, and returns the printed source string via @putout/printer. Returns '' if the file has not been parsed.
const code = print('src/index.js');
// 'let a = 1;\n'Same as print but returns the raw AST object.
const ast = putnik.getAst('src/index.js');import {sql} from 'putnik';
const query = sql`SELECT id FROM VariableDeclaration WHERE file = ${file}`;A no-op tag that enables SQL syntax highlighting in editors that support tagged templates.
A plugin is a plain object with three SQL strings. The @fix query can be UPDATE, DELETE, or INSERT β all three are valid:
// UPDATE β change a value
const constToLet = {
select: `SELECT id, type, file, start_line, start_col FROM VariableDeclaration WHERE file = :file AND kind = 'const'`,
report: `SELECT 'Prefer let over const' AS message, start_line AS line, start_col AS col FROM VariableDeclaration WHERE file = :file AND kind = 'const'`,
fix: `UPDATE VariableDeclaration SET kind = 'let' WHERE file = :file AND kind = 'const'`,
};
// DELETE β remove a node
const noDebugger = {
select: `SELECT id, type, file, start_line, start_col FROM DebuggerStatement WHERE file = :file`,
report: `SELECT 'Unexpected debugger statement' AS message, start_line AS line, start_col AS col FROM DebuggerStatement WHERE file = :file`,
fix: `DELETE FROM DebuggerStatement WHERE file = :file`,
};
// INSERT β add a new node
// The runner passes each @select row as params, so :id and :type refer to the matched row.
// The database generates the new node's id automatically.
const addDebugger = {
select: `SELECT id, type, file, start_line, start_col FROM BlockStatement WHERE file = :file`,
report: `SELECT 'Missing debugger' AS message, start_line AS line, start_col AS col FROM BlockStatement WHERE file = :file`,
fix: `
INSERT INTO DebuggerStatement (file, parent_id, parent_type, parent_field, start_line, start_col, end_line, end_col)
VALUES (:file, :id, :type, 'body', :start_line, :start_col, :start_line, :start_col)
`,
};Plugins can also be loaded from .sql files:
-- @select
SELECT id, type, file, start_line, start_col
FROM VariableDeclaration
WHERE file = :file AND kind = 'const';
-- @report
SELECT 'Prefer let over const' AS message,
start_line AS line, start_col AS col
FROM VariableDeclaration
WHERE file = :file AND kind = 'const';
-- @fix
UPDATE VariableDeclaration SET kind = 'let'
WHERE file = :file AND kind = 'const';import {loadSqlPlugin} from 'putnik';
const plugin = loadSqlPlugin('./plugins/const-to-let.sql');Boolean-like AST fields are stored as INTEGER. Use 0 and 1 in plugin SQL β not true or false:
-- find all async functions
SELECT id, type, file, start_line, start_col
FROM FunctionDeclaration
WHERE file = :file AND async = 1;
-- make all functions non-async
UPDATE FunctionDeclaration SET async = 0 WHERE file = :file;Plugins must use the common subset supported by both SQLite and Postgres. validatePlugin rejects non-portable constructs with a clear error:
| rejected | use instead |
|---|---|
FULL OUTER JOIN |
two LEFT JOINs with UNION ALL |
RIGHT JOIN |
LEFT JOIN with tables swapped |
REGEXP |
LIKE or IN |
ANY / ALL |
IN with a subquery |
true / false |
1 / 0 |
LATERAL |
correlated subquery |
Because all files share one DB, a plugin can query across the whole project:
import {readFileSync} from 'node:fs';
import {putnik} from 'putnik';
const [code] = putnik({
connection: '.putnik.db',
});
const unusedExports = {
select: `
SELECT e.id, e.type, e.file, e.start_line, e.start_col
FROM ExportDeclaration e
LEFT JOIN ImportDeclaration i ON i.name = e.name AND i.file != e.file
WHERE i.id IS NULL AND e.file = :file
`,
report: `
SELECT 'Unused export' AS message, start_line AS line, start_col AS col
FROM ExportDeclaration e
LEFT JOIN ImportDeclaration i ON i.name = e.name AND i.file != e.file
WHERE i.id IS NULL AND e.file = :file
`,
};
const [, places] = await putnik(targetFile, 'const a = "hello"', {
plugins: [unusedExports],
});MIT