Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

130 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Putnik License NPM version Build Status Coverage Status

IMG_5196 2

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.

Install

npm i putnik

Usage

import {
    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;

API

parse(file, source)

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.


putnik(file, source, options)

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.


print(file)

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'

getAst(file)

Same as print but returns the raw AST object.

const ast = putnik.getAst('src/index.js');

sql tagged template

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.

Plugin shape

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 columns

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;

Portable SQL

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

Cross-file transforms

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],
});

License

MIT

About

What if JavaScript AST was stored in SQL Database?

Resources

Code of conduct

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages