Implement table command to parse tsv, csv, and JSON

This commit is contained in:
2026-07-06 18:17:47 -05:00
parent ddfd1b6201
commit faf3a16767
7 changed files with 304 additions and 326 deletions

View File

@@ -58,6 +58,7 @@ import {If} from "./if.js";
import {Unless} from "./unless.js";
import {Convert} from "./convert.js";
import {While} from "./while.js";
import {Table} from "./table.js";
export type Commands = Command<CommandData>[]
export const commands: Commands = [
@@ -108,6 +109,7 @@ export const commands: Commands = [
new Sort,
new Split,
new Suffix,
new Table,
new Take,
new To,
new Trim,

114
src/vm/commands/table.ts Normal file
View File

@@ -0,0 +1,114 @@
import { parseString } from '@fast-csv/parse'
import {Command, ParseContext, wrapDestructured, wrapString} from "./command.js";
import {LexInput} from "../lexer.js";
import {Awaitable, JSONData} from "../../util/types.js";
import {ExecutionError, StrVM} from "../vm.js";
import {log} from "../../log.js";
export type TableData = {
source: 'json'|'csv'|'tsv',
}
const parseJsonToTable = (json: string): string[][] => {
let data: JSONData
try {
data = JSON.parse(json)
} catch (e: unknown) {
log.debug('parseJsonToTable', e)
throw new ExecutionError('Could not parse: subject is not valid JSON')
}
if ( !Array.isArray(data) ) {
throw new ExecutionError('Could not parse: JSON must be an array of records')
}
let tableData: string[][] = []
let orderedKeys: string[] = []
for ( const row of data ) {
if (
typeof row !== 'object'
|| !row
|| Array.isArray(row)
) {
throw new ExecutionError('Could not parse: JSON must be of the type {[string]: string|number|boolean}[]')
}
// Add any keys in this row that weren't already present to the end of orderedKeys
const missingKeys = Object.keys(row)
.filter(k => !orderedKeys.includes(k))
missingKeys.forEach(k => orderedKeys.push(k))
// Backfill the missing keys on any existing data
for ( const tableRow of tableData ) {
for ( const key of missingKeys ) {
tableRow.push('')
}
}
const tableRow: string[] = []
for ( const key of orderedKeys ) {
const val = row[key]
if ( typeof val === 'object' || typeof val === 'undefined' || typeof val === 'function' || Array.isArray(val) ) {
throw new ExecutionError('Could not parse: row values must be string|boolean|number')
}
tableRow.push(val.toString())
}
tableData.push(tableRow)
}
return tableData
}
const parseCsvToTable = (csvString: string): Promise<string[][]> =>
new Promise((resolve, reject) => {
const rows: string[][] = [];
parseString(csvString, { headers: false })
.on('error', (error) => reject(error))
.on('data', (row: string[]) => rows.push(row))
.on('end', () => resolve(rows));
})
const parseTsvToTable = (tsvString: string): Promise<string[][]> =>
new Promise((resolve, reject) => {
const rows: string[][] = [];
parseString(tsvString, { headers: false, delimiter: '\t' })
.on('error', (error) => reject(error))
.on('data', (row: string[]) => rows.push(row))
.on('end', () => resolve(rows));
})
export class Table extends Command<TableData> {
attemptParse(context: ParseContext): Awaitable<TableData> {
return {
source: context.popKeywordInSet(['json', 'csv', 'tsv']).value,
}
}
getDisplayName(): string {
return 'table'
}
isParseCandidate(token: LexInput): boolean {
return this.isKeyword(token, 'table')
}
async execute(vm: StrVM, data: TableData): Promise<StrVM> {
return vm.replaceContextMatchingTerm({
destructure: async val => {
const table: string[][] =
data.source === 'json' ? parseJsonToTable(val)
: data.source === 'csv' ? (await parseCsvToTable(val))
: data.source === 'tsv' ? (await parseTsvToTable(val))
: (() => { throw new ExecutionError('Unimplemented table source format') })()
return table.map(row => {
const cells = row.map(cellVal => ({ value: wrapString(cellVal) }))
return { value: wrapDestructured(cells) }
})
},
})
}
}