diff --git a/engines/algebra-sparql-1-1/README.md b/engines/algebra-sparql-1-1/README.md index 5c04c971..e06c4a96 100644 --- a/engines/algebra-sparql-1-1/README.md +++ b/engines/algebra-sparql-1-1/README.md @@ -253,3 +253,31 @@ and the project operation always gets used (even in the case of `SELECT *`). Every test consists of a sparql file and a corresponding json file containing the algebra result. Tests ending with `(quads)` in their name are tested/generated with `quads: true` in the options. + +## CLI + +This package exposes the `traqula-algebra-sparql-1-1` binary. + +Convert AST JSON to algebra JSON: + +```bash +traqula-algebra-sparql-1-1 --input ast.json --output algebra.json --pretty +``` + +Convert algebra JSON back to AST JSON: + +```bash +traqula-algebra-sparql-1-1 --from algebra --input algebra.json --output ast.json --pretty +``` + +Run as a long-lived JSONL service: + +```bash +traqula-algebra-sparql-1-1 --service +``` + +Request example: + +```json +{ "id": "1", "mode": "toAlgebra", "input": { "type": "query", "subType": "ask" }, "options": { "quads": false, "blankToVariable": false } } +``` diff --git a/engines/algebra-sparql-1-1/bin/traqula-algebra-sparql-1-1.ts b/engines/algebra-sparql-1-1/bin/traqula-algebra-sparql-1-1.ts new file mode 100644 index 00000000..7ff95abe --- /dev/null +++ b/engines/algebra-sparql-1-1/bin/traqula-algebra-sparql-1-1.ts @@ -0,0 +1,113 @@ +#!/usr/bin/env node +import type { Algebra, ContextConfigs } from '@traqula/algebra-transformations-1-1'; +import { + parsePrefixMappings, + readJsonInput, + runJsonlService, + writeJsonOutput, +} from '@traqula/cli-utils'; +import type { SparqlQuery } from '@traqula/rules-sparql-1-1'; +import { Command } from 'commander'; +import { createAlgebraCliRuntime, handleAlgebraCliRequest } from '../lib/cli.js'; + +function collectStrings(val: string, prev: string[]): string[] { + return [ ...prev, val ]; +} + +const program = new Command() + .name('traqula-algebra-sparql-1-1') + .description('Translate between Traqula SPARQL AST JSON and SPARQL algebra JSON.') + .option('-i, --input ', 'Read JSON input from file (defaults to stdin)') + .option('-o, --output ', 'Write JSON output to file (defaults to stdout)') + .option('--from ', 'Input format (default: ast)', 'ast') + .option('--quads', 'toAlgebra: convert patterns to quads') + .option('--blank-to-variable', 'toAlgebra: rewrite blank nodes to variables') + .option('--base-iri ', 'toAlgebra: base IRI for relative resolution') + .option('--prefix ', 'toAlgebra: predefined prefixes (repeatable)', collectStrings, []) + .option('--pretty', 'Pretty-print JSON output') + .option('--service', 'Run JSONL service mode over stdio') + .addHelpText('after', ` +Service request format: + {"id":"1","mode":"toAlgebra","input":{...},"options":{"quads":false}}`); + +interface Options { + input?: string; + output?: string; + from: string; + quads?: boolean; + blankToVariable?: boolean; + baseIri?: string; + prefix: string[]; + pretty?: boolean; + service?: boolean; +} + +function parseFromOption(value: string): 'toAlgebra' | 'toAst' { + if (value === 'ast') { + return 'toAlgebra'; + } + if (value === 'algebra') { + return 'toAst'; + } + throw new Error(`Invalid value for --from: ${value}. Expected 'ast' or 'algebra'`); +} + +function createToAlgebraOptions(opts: Options): ContextConfigs { + return { + quads: opts.quads, + blankToVariable: opts.blankToVariable, + baseIRI: opts.baseIri, + prefixes: parsePrefixMappings(opts.prefix), + }; +} + +program.action(async(opts: Options) => { + const runtime = createAlgebraCliRuntime(); + + if (opts.service) { + await runJsonlService((request: unknown) => { + if (request === null || typeof request !== 'object') { + throw new Error('Service request must be a JSON object'); + } + const data = <{ mode?: unknown; input?: unknown; options?: unknown }> request; + if (data.mode !== 'toAlgebra' && data.mode !== 'toAst') { + throw new Error('Service request must include mode: toAlgebra | toAst'); + } + if (data.input === undefined) { + throw new Error('Service request must include property: input'); + } + if (data.mode === 'toAlgebra') { + return handleAlgebraCliRequest(runtime, { + mode: 'toAlgebra', + input: data.input, + options: data.options, + }); + } + return handleAlgebraCliRequest(runtime, { + mode: 'toAst', + input: data.input, + }); + }); + return; + } + + const mode = parseFromOption(opts.from); + const input = await readJsonInput(opts.input); + const output = mode === 'toAlgebra' ? + handleAlgebraCliRequest(runtime, { + mode: 'toAlgebra', + input: input, + options: createToAlgebraOptions(opts), + }) : + handleAlgebraCliRequest(runtime, { + mode: 'toAst', + input: input, + }); + + await writeJsonOutput(output, opts.pretty ?? false, opts.output); +}); + +program.parseAsync().catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.message : 'Unknown error'}\n`); + process.exitCode = 1; +}); diff --git a/engines/algebra-sparql-1-1/lib/cli.ts b/engines/algebra-sparql-1-1/lib/cli.ts new file mode 100644 index 00000000..aafda44d --- /dev/null +++ b/engines/algebra-sparql-1-1/lib/cli.ts @@ -0,0 +1,46 @@ +import type { Algebra, ContextConfigs } from '@traqula/algebra-transformations-1-1'; +import { algebraUtils, createAlgebraContext, createAstContext } from '@traqula/algebra-transformations-1-1'; +import type { SparqlQuery } from '@traqula/rules-sparql-1-1'; +import { toAlgebra11Builder } from './toAlgebra.js'; +import { toAst11Builder } from './toAst.js'; + +export type AlgebraCliRequest = + | { + readonly mode: 'toAlgebra'; + readonly input: SparqlQuery; + readonly options?: ContextConfigs; + } + | { + readonly mode: 'toAst'; + readonly input: Algebra.Operation; + readonly options?: ContextConfigs; + }; + +export interface AlgebraCliRuntime { + readonly toAlgebraTransformer: ReturnType; + readonly toAstTransformer: ReturnType; +} + +export function createAlgebraCliRuntime(): AlgebraCliRuntime { + return { + toAlgebraTransformer: toAlgebra11Builder.build(), + toAstTransformer: toAst11Builder.build(), + }; +} + +export function handleAlgebraCliRequest(runtime: AlgebraCliRuntime, request: AlgebraCliRequest): unknown { + if (request.mode === 'toAlgebra') { + const options = request.options ?? {}; + const context = createAlgebraContext(options); + const operation = runtime.toAlgebraTransformer.translateQuery( + context, + request.input, + options.quads, + options.blankToVariable, + ); + return algebraUtils.objectify(operation); + } + + const context = createAstContext(); + return runtime.toAstTransformer.algToSparql(context, request.input); +} diff --git a/engines/algebra-sparql-1-1/lib/index.ts b/engines/algebra-sparql-1-1/lib/index.ts index d8cae0ba..09c9c42c 100644 --- a/engines/algebra-sparql-1-1/lib/index.ts +++ b/engines/algebra-sparql-1-1/lib/index.ts @@ -1,2 +1,3 @@ export * from './toAst.js'; export * from './toAlgebra.js'; +export * from './cli.js'; diff --git a/engines/algebra-sparql-1-1/package.json b/engines/algebra-sparql-1-1/package.json index 9ec2d3db..41909065 100644 --- a/engines/algebra-sparql-1-1/package.json +++ b/engines/algebra-sparql-1-1/package.json @@ -24,15 +24,24 @@ } }, "main": "dist/esm/lib/index.js", + "bin": { + "traqula-algebra-sparql-1-1": "dist/esm/bin/traqula-algebra-sparql-1-1.js" + }, "publishConfig": { "access": "public" }, "files": [ + "dist/*/bin/**/*.d.ts", + "dist/*/bin/**/*.js", + "dist/*/bin/**/*.js.map", "dist/*/lib/**/*.d.ts", "dist/*/lib/**/*.js", "dist/*/lib/**/*.js.map", "dist/cjs/package.json" ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, "scripts": { "build": "yarn build:ts && yarn build:cjs", "build:ts": "node \"../../node_modules/typescript/bin/tsc\" -b", @@ -40,13 +49,16 @@ }, "dependencies": { "@traqula/algebra-transformations-1-1": "^1.0.4", + "@traqula/cli-utils": "^1.0.4", "@traqula/core": "^1.0.3", - "@traqula/rules-sparql-1-1": "^1.0.4" + "@traqula/rules-sparql-1-1": "^1.0.4", + "commander": "^14.0.3" }, "devDependencies": { "@traqula/generator-sparql-1-1": "^1.0.4", "@traqula/parser-sparql-1-1": "^1.0.4", "@traqula/test-utils": "^1.0.4", + "@types/node": "^24.3.1", "sparqlalgebrajs": "^5.0.1", "sparqljs": "^3.7.3" } diff --git a/engines/algebra-sparql-1-1/test/cli.test.ts b/engines/algebra-sparql-1-1/test/cli.test.ts new file mode 100644 index 00000000..3ac6721f --- /dev/null +++ b/engines/algebra-sparql-1-1/test/cli.test.ts @@ -0,0 +1,193 @@ +import { Readable } from 'node:stream'; +import type { Algebra } from '@traqula/algebra-transformations-1-1'; +import type { JsonlResponse } from '@traqula/cli-utils'; +import { runJsonlService } from '@traqula/cli-utils'; +import { Parser } from '@traqula/parser-sparql-1-1'; +import { describe, it, expect } from 'vitest'; +import { createAlgebraCliRuntime, handleAlgebraCliRequest } from '../lib/cli.js'; + +function makeStream(lines: string[]): Readable { + return Readable.from(`${lines.join('\n')}\n`); +} + +function makeOutput(): { write: (chunk: string) => void; lines: () => JsonlResponse[] } { + const chunks: string[] = []; + return { + write(chunk: string) { + chunks.push(chunk); + }, + lines() { + return chunks.join('').split('\n').filter(l => l.length > 0).map(l => JSON.parse(l)); + }, + }; +} + +const parser = new Parser(); + +describe('algebra CLI runtime', () => { + it('converts AST to algebra and back', () => { + const runtime = createAlgebraCliRuntime(); + const ast = parser.parse('SELECT * WHERE { ?s ?p ?o }'); + + const algebra = handleAlgebraCliRequest(runtime, { + mode: 'toAlgebra', + input: ast, + }); + + expect(algebra).toHaveProperty('type'); + + const astAgain = <{ where?: unknown; loc?: unknown }> handleAlgebraCliRequest(runtime, { + mode: 'toAst', + input: algebra, + }); + + expect(astAgain.where).toBeDefined(); + expect(astAgain.loc).toBeDefined(); + }); + + it('converts AST to algebra with quads option', () => { + const runtime = createAlgebraCliRuntime(); + const ast = parser.parse('SELECT * WHERE { ?s ?p ?o }'); + + const algebra = handleAlgebraCliRequest(runtime, { + mode: 'toAlgebra', + input: ast, + options: { quads: true }, + }); + + expect(algebra).toHaveProperty('type'); + }); + + it('converts AST to algebra with blank-to-variable option', () => { + const runtime = createAlgebraCliRuntime(); + const ast = parser.parse('SELECT * WHERE { _:b0 ?p ?o }'); + + const algebra = handleAlgebraCliRequest(runtime, { + mode: 'toAlgebra', + input: ast, + options: { blankToVariable: true }, + }); + + expect(algebra).toHaveProperty('type'); + }); + + it('throws on invalid input for toAst', () => { + const runtime = createAlgebraCliRuntime(); + expect(() => handleAlgebraCliRequest(runtime, { + mode: 'toAst', + input: { type: 'UNKNOWN_OP' }, + })).toThrow(); + }); +}); + +describe('algebra service mode', () => { + it('converts AST to algebra via JSONL service', async() => { + const ast = parser.parse('SELECT * WHERE { ?s ?p ?o }'); + const input = makeStream([ JSON.stringify({ id: '1', mode: 'toAlgebra', input: ast }) ]); + const output = makeOutput(); + const runtime = createAlgebraCliRuntime(); + + await runJsonlService( + (request: unknown) => { + const data = <{ mode: 'toAlgebra'; input: never }> request; + return handleAlgebraCliRequest(runtime, { mode: data.mode, input: data.input }); + }, + { input, output }, + ); + + const lines = output.lines(); + expect(lines).toHaveLength(1); + expect(lines[0].ok).toBe(true); + expect(lines[0].result).toHaveProperty('type'); + }); + + it('converts algebra to AST via JSONL service', async() => { + const runtime = createAlgebraCliRuntime(); + const ast = parser.parse('SELECT * WHERE { ?s ?p ?o }'); + const algebra = handleAlgebraCliRequest(runtime, { mode: 'toAlgebra', input: ast }); + + const input = makeStream([ JSON.stringify({ id: '2', mode: 'toAst', input: algebra }) ]); + const output = makeOutput(); + + await runJsonlService( + (request: unknown) => { + const data = <{ mode: 'toAst'; input: never }> request; + return handleAlgebraCliRequest(runtime, { mode: data.mode, input: data.input }); + }, + { input, output }, + ); + + const lines = output.lines(); + expect(lines[0]).toMatchObject({ id: '2', ok: true }); + expect((> lines[0].result).where).toBeDefined(); + }); + + it('returns error when mode is missing or invalid', async() => { + const input = makeStream([ '{"id":"x","input":{}}' ]); + const output = makeOutput(); + + await runJsonlService( + (request: unknown) => { + const data = <{ mode?: unknown }> request; + if (data.mode !== 'toAlgebra' && data.mode !== 'toAst') { + throw new Error('Service request must include mode: toAlgebra | toAst'); + } + return 'ok'; + }, + { input, output }, + ); + + expect(output.lines()[0]).toMatchObject({ id: 'x', ok: false }); + expect(output.lines()[0].error?.message).toContain('mode'); + }); + + it('returns error when input is missing', async() => { + const input = makeStream([ '{"id":"y","mode":"toAlgebra"}' ]); + const output = makeOutput(); + + await runJsonlService( + (request: unknown) => { + const data = <{ mode: string; input?: unknown }> request; + if (data.input === undefined) { + throw new Error('Service request must include property: input'); + } + return 'ok'; + }, + { input, output }, + ); + + expect(output.lines()[0]).toMatchObject({ id: 'y', ok: false }); + expect(output.lines()[0].error?.message).toContain('input'); + }); + + it('processes a batch of toAlgebra requests', async() => { + const runtime = createAlgebraCliRuntime(); + const queries = [ + 'SELECT * WHERE { ?s ?p ?o }', + 'ASK { ?x ?y ?z }', + 'SELECT ?name WHERE { ?s ?name }', + ]; + const asts = queries.map((q, i) => JSON.stringify({ + id: String(i), + mode: 'toAlgebra', + input: parser.parse(q), + })); + + const inputStream = makeStream(asts); + const outputStream = makeOutput(); + + await runJsonlService( + (request: unknown) => { + const data = <{ mode: 'toAlgebra'; input: never }> request; + return handleAlgebraCliRequest(runtime, { mode: data.mode, input: data.input }); + }, + { input: inputStream, output: outputStream }, + ); + + const lines = outputStream.lines(); + expect(lines).toHaveLength(3); + for (const line of lines) { + expect(line.ok).toBe(true); + } + }); +}); diff --git a/engines/algebra-sparql-1-1/tsconfig.json b/engines/algebra-sparql-1-1/tsconfig.json index ad38e422..d8fb0f95 100644 --- a/engines/algebra-sparql-1-1/tsconfig.json +++ b/engines/algebra-sparql-1-1/tsconfig.json @@ -3,10 +3,12 @@ "compilerOptions": { "composite": true, "rootDir": ".", + "types": ["vitest/globals", "node"], "declaration": true, "outDir": "dist/esm" }, "references": [ + { "path": "../../packages/cli-utils" }, { "path": "../../packages/core" }, { "path": "../../packages/algebra-transformations-1-1" }, { "path": "../../packages/rules-sparql-1-1" }, @@ -14,6 +16,6 @@ { "path": "../generator-sparql-1-1" }, { "path": "../parser-sparql-1-1" } ], - "include": ["lib/**/*.ts", "test/**/*.ts"], + "include": ["lib/**/*.ts", "bin/**/*.ts", "test/**/*.ts"], "exclude": ["dist/**"] } diff --git a/engines/generator-sparql-1-1/README.md b/engines/generator-sparql-1-1/README.md index fdd3d552..d7fff59f 100644 --- a/engines/generator-sparql-1-1/README.md +++ b/engines/generator-sparql-1-1/README.md @@ -82,3 +82,31 @@ Whenever this number is negative, no newline will be printed. _(default: 0)_ By default, the generator will emit the round tripped query string where possible. In order to create an AST that supports round-tripping, you should make sure the [parser is set up correctly](../parser-sparql-1-1/README.md#collecting-round-tripping-information). + +## CLI + +This package exposes the `traqula-generator-sparql-1-1` binary. + +Generate query text from an AST JSON file: + +```bash +traqula-generator-sparql-1-1 --input ast.json --output query.sparql +``` + +Compact output (single line): + +```bash +traqula-generator-sparql-1-1 --input ast.json --compact +``` + +Run as a long-lived JSONL service: + +```bash +traqula-generator-sparql-1-1 --service +``` + +Request example: + +```json +{ "id": "1", "ast": { "type": "query", "subType": "ask" }, "path": false, "context": {} } +``` diff --git a/engines/generator-sparql-1-1/bin/traqula-generator-sparql-1-1.ts b/engines/generator-sparql-1-1/bin/traqula-generator-sparql-1-1.ts new file mode 100644 index 00000000..1e6bb681 --- /dev/null +++ b/engines/generator-sparql-1-1/bin/traqula-generator-sparql-1-1.ts @@ -0,0 +1,100 @@ +#!/usr/bin/env node +import { + readJsonInput, + runJsonlService, + writeTextOutput, +} from '@traqula/cli-utils'; +import { traqulaIndentation, traqulaNewlineAlternative } from '@traqula/core'; +import type { SparqlGeneratorContext, Path, Query, Update } from '@traqula/rules-sparql-1-1'; +import { Command } from 'commander'; +import { createGeneratorCliRuntime, handleGeneratorCliRequest } from '../lib/cli.js'; + +const program = new Command() + .name('traqula-generator-sparql-1-1') + .description('Generate SPARQL 1.1 text from a Traqula AST JSON input.') + .option('-i, --input ', 'Read AST JSON from file (defaults to stdin)') + .option('-o, --output ', 'Write generated SPARQL to file (defaults to stdout)') + .option('--path', 'Treat input AST as a SPARQL path') + .option('--compact', 'Disable pretty printing and newlines') + .option('--indent ', 'Configure indentation width') + .option('--newline-alt ', 'Separator used when compact mode disables newlines') + .option('--service', 'Run JSONL service mode over stdio') + .addHelpText('after', ` +Service request format: + {"id":"1","ast":{...},"path":false,"context":{...}}`); + +interface Options { + input?: string; + output?: string; + path?: boolean; + compact?: boolean; + indent?: string; + newlineAlt?: string; + service?: boolean; +} + +function parseNonNegativeInt(value: string, option: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error(`Invalid value for --${option}: must be a non-negative integer`); + } + return parsed; +} + +function createDefaultContext(opts: Options): Partial { + const context: Partial = {}; + + if (opts.indent !== undefined) { + context.indentInc = parseNonNegativeInt(opts.indent, 'indent'); + } + + if (opts.newlineAlt !== undefined) { + context[traqulaNewlineAlternative] = opts.newlineAlt; + } + + if (opts.compact) { + context[traqulaIndentation] = -1; + context.indentInc = 0; + } + + return context; +} + +program.action(async(opts: Options) => { + const runtime = createGeneratorCliRuntime(createDefaultContext(opts)); + + if (opts.service) { + await runJsonlService((request: unknown) => { + if (request === null || typeof request !== 'object') { + throw new Error('Service request must be a JSON object'); + } + const data = <{ ast?: unknown; path?: unknown; context?: unknown }> request; + if (data.ast === undefined) { + throw new Error('Missing property: ast'); + } + if (data.path) { + return handleGeneratorCliRequest(runtime, { + ast: data.ast, + path: true, + context: | undefined> data.context, + }); + } + return handleGeneratorCliRequest(runtime, { + ast: data.ast, + context: | undefined> data.context, + }); + }); + return; + } + + const ast = await readJsonInput(opts.input); + const output = opts.path ? + handleGeneratorCliRequest(runtime, { ast: ast, path: true }) : + handleGeneratorCliRequest(runtime, { ast: ast }); + await writeTextOutput(output, opts.output); +}); + +program.parseAsync().catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.message : 'Unknown error'}\n`); + process.exitCode = 1; +}); diff --git a/engines/generator-sparql-1-1/lib/cli.ts b/engines/generator-sparql-1-1/lib/cli.ts new file mode 100644 index 00000000..26b8af68 --- /dev/null +++ b/engines/generator-sparql-1-1/lib/cli.ts @@ -0,0 +1,46 @@ +import type { SparqlGeneratorContext, Path, Query, Update } from '@traqula/rules-sparql-1-1'; +import { Generator } from './index.js'; + +export type GeneratorCliRequest = + | { + readonly ast: Path; + readonly path: true; + readonly context?: Partial; + } + | { + readonly ast: Query | Update; + readonly path?: false; + readonly context?: Partial; + }; + +export interface GeneratorCliRuntime { + readonly generator: Generator; + readonly defaultContext: Partial; +} + +export function createGeneratorCliRuntime(defaultContext: Partial = {}): GeneratorCliRuntime { + return { + generator: new Generator(defaultContext), + defaultContext, + }; +} + +export function handleGeneratorCliRequest(runtime: GeneratorCliRuntime, request: GeneratorCliRequest): string { + const context = mergeContext(runtime.defaultContext, request.context); + return request.path ? + runtime.generator.generatePath(request.ast, context) : + runtime.generator.generate(request.ast, context); +} + +function mergeContext( + defaults: Partial, + override: Partial | undefined, +): Partial { + if (!override) { + return defaults; + } + return { + ...defaults, + ...override, + }; +} diff --git a/engines/generator-sparql-1-1/lib/index.ts b/engines/generator-sparql-1-1/lib/index.ts index 63b7b2e8..ff132e7b 100644 --- a/engines/generator-sparql-1-1/lib/index.ts +++ b/engines/generator-sparql-1-1/lib/index.ts @@ -118,3 +118,5 @@ export class Generator { return this.generator.path(ast, completeGeneratorContext({ ...this.defaultContext, ...context }), undefined).trim(); } } + +export * from './cli.js'; diff --git a/engines/generator-sparql-1-1/package.json b/engines/generator-sparql-1-1/package.json index c863c39a..159c9b4e 100644 --- a/engines/generator-sparql-1-1/package.json +++ b/engines/generator-sparql-1-1/package.json @@ -23,10 +23,16 @@ } }, "main": "dist/esm/lib/index.js", + "bin": { + "traqula-generator-sparql-1-1": "dist/esm/bin/traqula-generator-sparql-1-1.js" + }, "publishConfig": { "access": "public" }, "files": [ + "dist/*/bin/**/*.d.ts", + "dist/*/bin/**/*.js", + "dist/*/bin/**/*.js.map", "dist/*/lib/**/*.d.ts", "dist/*/lib/**/*.js", "dist/*/lib/**/*.js.map", @@ -41,11 +47,14 @@ "build:cjs": "node \"../../node_modules/typescript/bin/tsc\" -b tsconfig.cjs.json" }, "dependencies": { + "@traqula/cli-utils": "^1.0.4", "@traqula/core": "^1.0.3", - "@traqula/rules-sparql-1-1": "^1.0.4" + "@traqula/rules-sparql-1-1": "^1.0.4", + "commander": "^14.0.3" }, "devDependencies": { "@traqula/parser-sparql-1-1": "^1.0.4", - "@traqula/test-utils": "^1.0.4" + "@traqula/test-utils": "^1.0.4", + "@types/node": "^24.3.1" } } diff --git a/engines/generator-sparql-1-1/test/cli.test.ts b/engines/generator-sparql-1-1/test/cli.test.ts new file mode 100644 index 00000000..03872851 --- /dev/null +++ b/engines/generator-sparql-1-1/test/cli.test.ts @@ -0,0 +1,135 @@ +import { Readable } from 'node:stream'; +import type { JsonlResponse } from '@traqula/cli-utils'; +import { runJsonlService } from '@traqula/cli-utils'; +import { Parser } from '@traqula/parser-sparql-1-1'; +import { describe, it, expect } from 'vitest'; +import { createGeneratorCliRuntime, handleGeneratorCliRequest } from '../lib/cli.js'; + +function makeStream(lines: string[]): Readable { + return Readable.from(`${lines.join('\n')}\n`); +} + +function makeOutput(): { write: (chunk: string) => void; lines: () => JsonlResponse[] } { + const chunks: string[] = []; + return { + write(chunk: string) { + chunks.push(chunk); + }, + lines() { + return chunks.join('').split('\n').filter(l => l.length > 0).map(l => JSON.parse(l)); + }, + }; +} + +const parser = new Parser(); + +describe('generator CLI runtime', () => { + it('generates a query from AST', () => { + const runtime = createGeneratorCliRuntime(); + const ast = parser.parse('SELECT * WHERE { ?s ?p ?o }'); + + const output = handleGeneratorCliRequest(runtime, { ast }); + expect(output).toContain('SELECT'); + expect(output).toContain('?s ?p ?o'); + }); + + it('generates a path expression from AST', () => { + const parserForPath = new Parser({ defaultContext: { parseMode: new Set([ 'canParseVars' ]) }}); + const runtime = createGeneratorCliRuntime(); + const pathAst = parserForPath.parsePath(' / '); + + const output = handleGeneratorCliRequest(runtime, { ast: pathAst, path: true }); + expect(output).toContain('http://example.org/p'); + }); + + it('throws on an invalid AST object', () => { + const runtime = createGeneratorCliRuntime(); + expect(() => handleGeneratorCliRequest(runtime, { ast: { invalid: true }})).toThrow(); + }); +}); + +describe('generator service mode', () => { + it('generates SPARQL from AST via JSONL service', async() => { + const ast = parser.parse('SELECT * WHERE { ?s ?p ?o }'); + const input = makeStream([ JSON.stringify({ id: '1', ast }) ]); + const output = makeOutput(); + const runtime = createGeneratorCliRuntime(); + + await runJsonlService( + (request: unknown) => { + const data = <{ ast: unknown }> request; + return handleGeneratorCliRequest(runtime, { ast: data.ast }); + }, + { input, output }, + ); + + const lines = output.lines(); + expect(lines).toHaveLength(1); + expect(lines[0].ok).toBe(true); + expect(lines[0].result).toContain('SELECT'); + }); + + it('returns error when ast property is missing', async() => { + const input = makeStream([ '{"id":"x"}' ]); + const output = makeOutput(); + + await runJsonlService( + (request: unknown) => { + const data = <{ ast?: unknown }> request; + if (data.ast === undefined) { + throw new Error('Missing property: ast'); + } + return 'ok'; + }, + { input, output }, + ); + + const lines = output.lines(); + expect(lines[0]).toMatchObject({ id: 'x', ok: false }); + expect(lines[0].error?.message).toContain('ast'); + }); + + it('processes multiple generate requests in sequence', async() => { + const ast1 = parser.parse('SELECT * WHERE { ?s ?p ?o }'); + const ast2 = parser.parse('ASK { ?x ?y ?z }'); + + const input = makeStream([ + JSON.stringify({ id: '1', ast: ast1 }), + JSON.stringify({ id: '2', ast: ast2 }), + ]); + const output = makeOutput(); + const runtime = createGeneratorCliRuntime(); + + await runJsonlService( + (request: unknown) => { + const data = <{ ast: never }> request; + return handleGeneratorCliRequest(runtime, { ast: data.ast }); + }, + { input, output }, + ); + + const lines = output.lines(); + expect(lines).toHaveLength(2); + expect(lines[0]).toMatchObject({ id: '1', ok: true }); + expect(lines[1]).toMatchObject({ id: '2', ok: true }); + expect(lines[1].result).toContain('ASK'); + }); + + it('returns error when handler throws', async() => { + const input = makeStream([ '{"id":"bad","ast":{"type":"invalid"}}' ]); + const output = makeOutput(); + const runtime = createGeneratorCliRuntime(); + + await runJsonlService( + (request: unknown) => { + const data = <{ ast: never }> request; + return handleGeneratorCliRequest(runtime, { ast: data.ast }); + }, + { input, output }, + ); + + const lines = output.lines(); + expect(lines[0]).toMatchObject({ id: 'bad', ok: false }); + expect(lines[0].error?.message).toBeDefined(); + }); +}); diff --git a/engines/generator-sparql-1-1/tsconfig.json b/engines/generator-sparql-1-1/tsconfig.json index 4bae1370..5fbe8862 100644 --- a/engines/generator-sparql-1-1/tsconfig.json +++ b/engines/generator-sparql-1-1/tsconfig.json @@ -3,15 +3,17 @@ "compilerOptions": { "composite": true, "rootDir": ".", + "types": ["vitest/globals", "node"], "declaration": true, "outDir": "dist/esm" }, "references": [ + { "path": "../../packages/cli-utils" }, { "path": "../../packages/core" }, { "path": "../../packages/rules-sparql-1-1" }, { "path": "../../packages/test-utils" }, { "path": "../parser-sparql-1-1" } ], - "include": ["lib/**/*.ts", "test/**/*.ts"], + "include": ["lib/**/*.ts", "bin/**/*.ts", "test/**/*.ts"], "exclude": ["dist/**"] } diff --git a/engines/parser-sparql-1-1/README.md b/engines/parser-sparql-1-1/README.md index 2a2bd484..e77d0998 100644 --- a/engines/parser-sparql-1-1/README.md +++ b/engines/parser-sparql-1-1/README.md @@ -122,3 +122,31 @@ const sourceTrackingParser = new Parser({ lexerConfig: { positionTracking: 'full' }, }); ``` + +## CLI + +This package exposes the `traqula-parser-sparql-1-1` binary. + +Parse from stdin to JSON output: + +```bash +echo 'SELECT * WHERE { ?s ?p ?o }' | traqula-parser-sparql-1-1 --pretty +``` + +Parse from file and write JSON to a file: + +```bash +traqula-parser-sparql-1-1 --input query.sparql --output ast.json +``` + +Run as a long-lived JSONL service (one JSON request per line): + +```bash +traqula-parser-sparql-1-1 --service +``` + +Request example: + +```json +{ "id": "1", "query": "SELECT * WHERE { ?s ?p ?o }", "path": false, "context": { "skipValidation": false } } +``` diff --git a/engines/parser-sparql-1-1/bin/traqula-parser-sparql-1-1.ts b/engines/parser-sparql-1-1/bin/traqula-parser-sparql-1-1.ts new file mode 100644 index 00000000..ccd16e13 --- /dev/null +++ b/engines/parser-sparql-1-1/bin/traqula-parser-sparql-1-1.ts @@ -0,0 +1,98 @@ +#!/usr/bin/env node +import { + parsePrefixMappings, + readTextInput, + runJsonlService, + writeJsonOutput, +} from '@traqula/cli-utils'; +import type { SparqlContext } from '@traqula/rules-sparql-1-1'; +import { Command } from 'commander'; +import { createParserCliRuntime, handleParserCliRequest } from '../lib/cli.js'; + +function collectStrings(val: string, prev: string[]): string[] { + return [ ...prev, val ]; +} + +const program = new Command() + .name('traqula-parser-sparql-1-1') + .description('Parse SPARQL 1.1 input into a Traqula AST (JSON).') + .option('-i, --input ', 'Read query text from file (defaults to stdin)') + .option('-o, --output ', 'Write JSON output to file (defaults to stdout)') + .option('--path', 'Parse input as a SPARQL path expression') + .option('--base-iri ', 'Set default base IRI') + .option('--prefix ', 'Set default prefix mapping (repeatable)', collectStrings, []) + .option('--skip-validation', 'Skip parser validation checks') + .option('--allow-vars', 'Enable variable parsing mode') + .option('--allow-blank-nodes', 'Enable blank node creation mode') + .option('--pretty', 'Pretty-print JSON output') + .option('--service', 'Run JSONL service mode over stdio') + .addHelpText('after', ` +Service request format: + {"id":"1","query":"SELECT * WHERE { ?s ?p ?o }","path":false,"context":{...}}`); + +interface Options { + input?: string; + output?: string; + path?: boolean; + baseIri?: string; + prefix: string[]; + skipValidation?: boolean; + allowVars?: boolean; + allowBlankNodes?: boolean; + pretty?: boolean; + service?: boolean; +} + +function createDefaultContext(opts: Options): Partial { + const parseMode = new Set(); + if (opts.allowVars) { + parseMode.add('canParseVars'); + } + if (opts.allowBlankNodes) { + parseMode.add('canCreateBlankNodes'); + } + + const context: Partial = { + baseIRI: opts.baseIri, + skipValidation: opts.skipValidation, + prefixes: parsePrefixMappings(opts.prefix), + }; + if (parseMode.size > 0) { + context.parseMode = parseMode; + } + return context; +} + +program.action(async(opts: Options) => { + const runtime = createParserCliRuntime(createDefaultContext(opts)); + + if (opts.service) { + await runJsonlService((request: unknown) => { + if (request === null || typeof request !== 'object') { + throw new Error('Service request must be a JSON object'); + } + const data = <{ query?: unknown; path?: unknown; context?: unknown }> request; + if (typeof data.query !== 'string') { + throw new TypeError('Missing string property: query'); + } + return handleParserCliRequest(runtime, { + query: data.query, + path: Boolean(data.path), + context: | undefined> data.context, + }); + }); + return; + } + + const query = await readTextInput(opts.input); + const output = handleParserCliRequest(runtime, { + query, + path: opts.path ?? false, + }); + await writeJsonOutput(output, opts.pretty ?? false, opts.output); +}); + +program.parseAsync().catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.message : 'Unknown error'}\n`); + process.exitCode = 1; +}); diff --git a/engines/parser-sparql-1-1/lib/cli.ts b/engines/parser-sparql-1-1/lib/cli.ts new file mode 100644 index 00000000..f317791e --- /dev/null +++ b/engines/parser-sparql-1-1/lib/cli.ts @@ -0,0 +1,52 @@ +import type { SparqlContext } from '@traqula/rules-sparql-1-1'; +import { Parser } from './Parser.js'; + +export interface ParserCliRequest { + readonly query: string; + readonly path?: boolean; + readonly context?: Partial; +} + +export interface ParserCliRuntime { + readonly parser: Parser; + readonly defaultContext: Partial; +} + +export function createParserCliRuntime(defaultContext: Partial = {}): ParserCliRuntime { + return { + parser: new Parser({ defaultContext }), + defaultContext, + }; +} + +export function handleParserCliRequest(runtime: ParserCliRuntime, request: ParserCliRequest): unknown { + const context = mergeContext(runtime.defaultContext, request.context); + return request.path ? runtime.parser.parsePath(request.query, context) : runtime.parser.parse(request.query, context); +} + +function mergeContext( + defaults: Partial, + override: Partial | undefined, +): Partial { + if (!override) { + return defaults; + } + + const parseMode = override.parseMode ?? defaults.parseMode; + const merged: Partial = { + ...defaults, + ...override, + prefixes: { ...defaults.prefixes, ...override.prefixes }, + }; + + // Avoid setting parseMode to undefined: an explicit undefined overwrites the + // parser's built-in default Set (canParseVars + canCreateBlankNodes) when the + // partial context is spread inside Parser.parse / Parser.parsePath. + if (parseMode === undefined) { + delete merged.parseMode; + } else { + merged.parseMode = new Set(parseMode); + } + + return merged; +} diff --git a/engines/parser-sparql-1-1/lib/index.ts b/engines/parser-sparql-1-1/lib/index.ts index cfd4fe97..da4d07ac 100644 --- a/engines/parser-sparql-1-1/lib/index.ts +++ b/engines/parser-sparql-1-1/lib/index.ts @@ -7,3 +7,4 @@ export * from './triplesBlockParser.js'; export * from './triplesTemplateParserBuilder.js'; export * from './updateNoModifyParser.js'; export * from './updateUnitParser.js'; +export * from './cli.js'; diff --git a/engines/parser-sparql-1-1/package.json b/engines/parser-sparql-1-1/package.json index 056fc809..4fddf610 100644 --- a/engines/parser-sparql-1-1/package.json +++ b/engines/parser-sparql-1-1/package.json @@ -23,10 +23,16 @@ } }, "main": "dist/esm/lib/index.js", + "bin": { + "traqula-parser-sparql-1-1": "dist/esm/bin/traqula-parser-sparql-1-1.js" + }, "publishConfig": { "access": "public" }, "files": [ + "dist/*/bin/**/*.d.ts", + "dist/*/bin/**/*.js", + "dist/*/bin/**/*.js.map", "dist/*/lib/**/*.d.ts", "dist/*/lib/**/*.js", "dist/*/lib/**/*.js.map", @@ -50,11 +56,14 @@ "spec:earl": "yarn spec:earl:base-1-0 && yarn spec:earl:query && yarn spec:earl:update" }, "dependencies": { + "@traqula/cli-utils": "^1.0.4", "@traqula/core": "^1.0.3", - "@traqula/rules-sparql-1-1": "^1.0.4" + "@traqula/rules-sparql-1-1": "^1.0.4", + "commander": "^14.0.3" }, "devDependencies": { "@traqula/test-utils": "^1.0.4", + "@types/node": "^24.3.1", "@types/sparqljs": "^3.1.12", "rdf-data-factory": "^2.0.1", "rdf-test-suite": "^2.0.0", diff --git a/engines/parser-sparql-1-1/test/cli.test.ts b/engines/parser-sparql-1-1/test/cli.test.ts new file mode 100644 index 00000000..d7b481a0 --- /dev/null +++ b/engines/parser-sparql-1-1/test/cli.test.ts @@ -0,0 +1,164 @@ +import { Readable } from 'node:stream'; +import type { JsonlResponse } from '@traqula/cli-utils'; +import { runJsonlService } from '@traqula/cli-utils'; +import { describe, it, expect } from 'vitest'; +import { createParserCliRuntime, handleParserCliRequest } from '../lib/cli.js'; + +function makeStream(lines: string[]): Readable { + return Readable.from(`${lines.join('\n')}\n`); +} + +function makeOutput(): { write: (chunk: string) => void; lines: () => JsonlResponse[] } { + const chunks: string[] = []; + return { + write(chunk: string) { + chunks.push(chunk); + }, + lines() { + return chunks.join('').split('\n').filter(l => l.length > 0).map(l => JSON.parse(l)); + }, + }; +} + +describe('parser CLI runtime', () => { + it('parses a query', () => { + const runtime = createParserCliRuntime(); + const ast = <{ loc?: unknown; where?: unknown }> handleParserCliRequest(runtime, { + query: 'SELECT * WHERE { ?s ?p ?o }', + }); + + expect(ast.loc).toBeDefined(); + expect(ast.where).toBeDefined(); + }); + + it('parses a path expression', () => { + const runtime = createParserCliRuntime(); + const path = <{ loc?: unknown }> handleParserCliRequest(runtime, { + query: ' / ', + path: true, + }); + + expect(path.loc).toBeDefined(); + }); + + it('merges per-request context with runtime defaults', () => { + const runtime = createParserCliRuntime({ + prefixes: { ex: 'http://example.org/' }, + }); + // The per-request context overrides skipValidation; the runtime default + // prefixes should still be merged and variable parsing must still work. + const ast = <{ where?: unknown }> handleParserCliRequest(runtime, { + query: 'SELECT * WHERE { ?s ?p ?o }', + context: { skipValidation: true }, + }); + expect(ast.where).toBeDefined(); + }); + + it('throws on syntactically invalid query', () => { + const runtime = createParserCliRuntime(); + expect(() => handleParserCliRequest(runtime, { query: '{ { {' })).toThrow(); + }); +}); + +describe('parser service mode', () => { + it('parses a query via JSONL service', async() => { + const input = makeStream([ '{"id":"1","query":"SELECT * WHERE { ?s ?p ?o }"}' ]); + const output = makeOutput(); + const runtime = createParserCliRuntime(); + + await runJsonlService( + (request: unknown) => { + const data = <{ query: string; path?: boolean }> request; + return handleParserCliRequest(runtime, { query: data.query, path: data.path ?? false }); + }, + { input, output }, + ); + + const lines = output.lines(); + expect(lines).toHaveLength(1); + expect(lines[0].ok).toBe(true); + expect(lines[0].id).toBe('1'); + expect(lines[0].result).toHaveProperty('where'); + }); + + it('returns error response when query is syntactically invalid', async() => { + const input = makeStream([ '{"id":"bad","query":"{ { {"}' ]); + const output = makeOutput(); + const runtime = createParserCliRuntime(); + + await runJsonlService( + (request: unknown) => { + const data = <{ query: string }> request; + return handleParserCliRequest(runtime, { query: data.query }); + }, + { input, output }, + ); + + const lines = output.lines(); + expect(lines).toHaveLength(1); + expect(lines[0].ok).toBe(false); + expect(lines[0].id).toBe('bad'); + expect(lines[0].error?.message).toBeDefined(); + }); + + it('processes multiple requests and preserves per-request context', async() => { + const input = makeStream([ + '{"id":"1","query":"SELECT * WHERE { ?s ?p ?o }"}', + '{"id":"2","query":"ASK { ?x ?y ?z }"}', + ]); + const output = makeOutput(); + const runtime = createParserCliRuntime(); + + await runJsonlService( + (request: unknown) => { + const data = <{ query: string }> request; + return handleParserCliRequest(runtime, { query: data.query }); + }, + { input, output }, + ); + + const lines = output.lines(); + expect(lines).toHaveLength(2); + expect(lines[0]).toMatchObject({ id: '1', ok: true }); + expect(lines[1]).toMatchObject({ id: '2', ok: true }); + }); + + it('returns error when request is not an object', async() => { + const input = makeStream([ '"just a string"' ]); + const output = makeOutput(); + + await runJsonlService( + (request: unknown) => { + if (request === null || typeof request !== 'object') { + throw new Error('Service request must be a JSON object'); + } + return 'ok'; + }, + { input, output }, + ); + + const lines = output.lines(); + expect(lines[0].ok).toBe(false); + expect(lines[0].error?.message).toContain('JSON object'); + }); + + it('returns error when query property is missing', async() => { + const input = makeStream([ '{"id":"x","path":false}' ]); + const output = makeOutput(); + + await runJsonlService( + (request: unknown) => { + const data = <{ query?: unknown }> request; + if (typeof data.query !== 'string') { + throw new TypeError('Missing string property: query'); + } + return 'ok'; + }, + { input, output }, + ); + + const lines = output.lines(); + expect(lines[0]).toMatchObject({ id: 'x', ok: false }); + expect(lines[0].error?.message).toContain('query'); + }); +}); diff --git a/engines/parser-sparql-1-1/tsconfig.json b/engines/parser-sparql-1-1/tsconfig.json index ef3a821d..da6851ab 100644 --- a/engines/parser-sparql-1-1/tsconfig.json +++ b/engines/parser-sparql-1-1/tsconfig.json @@ -3,14 +3,16 @@ "compilerOptions": { "composite": true, "rootDir": ".", + "types": ["vitest/globals", "node"], "declaration": true, "outDir": "dist/esm" }, "references": [ + { "path": "../../packages/cli-utils" }, { "path": "../../packages/core" }, { "path": "../../packages/rules-sparql-1-1" }, { "path": "../../packages/test-utils" } ], - "include": ["lib/**/*.ts", "test/**/*.ts", "spec/**/*.ts"], + "include": ["lib/**/*.ts", "bin/**/*.ts", "test/**/*.ts", "spec/**/*.ts"], "exclude": ["dist/**"] } diff --git a/package.json b/package.json index 88ae0b05..69ea924f 100644 --- a/package.json +++ b/package.json @@ -39,5 +39,8 @@ "typescript": "^5.7.3", "vite": "^8.0.0", "vitest": "^4.0.18" + }, + "resolutions": { + "@types/node": "^24.3.1" } } diff --git a/packages/cli-utils/lib/index.ts b/packages/cli-utils/lib/index.ts new file mode 100644 index 00000000..419ba4e0 --- /dev/null +++ b/packages/cli-utils/lib/index.ts @@ -0,0 +1,3 @@ +export * from './io.js'; +export * from './prefixes.js'; +export * from './service.js'; diff --git a/packages/cli-utils/lib/io.ts b/packages/cli-utils/lib/io.ts new file mode 100644 index 00000000..d6b4976a --- /dev/null +++ b/packages/cli-utils/lib/io.ts @@ -0,0 +1,36 @@ +/* eslint-disable import/no-nodejs-modules */ +import fs from 'node:fs/promises'; + +export async function readTextInput(filePath?: string): Promise { + if (filePath) { + return fs.readFile(filePath, 'utf8'); + } + + const chunks: string[] = []; + for await (const chunk of process.stdin) { + chunks.push(typeof chunk === 'string' ? chunk : ( chunk).toString('utf8')); + } + return chunks.join(''); +} + +export async function writeTextOutput(text: string, filePath?: string): Promise { + if (filePath) { + await fs.writeFile(filePath, text, 'utf8'); + return; + } + process.stdout.write(text); + if (!text.endsWith('\n')) { + process.stdout.write('\n'); + } +} + +export async function readJsonInput(filePath?: string): Promise { + const text = await readTextInput(filePath); + return JSON.parse(text); +} + +export async function writeJsonOutput(value: unknown, pretty: boolean, filePath?: string): Promise { + const spacing = pretty ? 2 : undefined; + const output = `${JSON.stringify(value, null, spacing)}\n`; + await writeTextOutput(output, filePath); +} diff --git a/packages/cli-utils/lib/prefixes.ts b/packages/cli-utils/lib/prefixes.ts new file mode 100644 index 00000000..e0b08e91 --- /dev/null +++ b/packages/cli-utils/lib/prefixes.ts @@ -0,0 +1,13 @@ +export function parsePrefixMappings(values: readonly string[]): Record { + const prefixes: Record = {}; + for (const entry of values) { + const separator = entry.indexOf('='); + if (separator <= 0 || separator === entry.length - 1) { + throw new Error(`Invalid prefix mapping '${entry}', expected prefix=iri`); + } + const key = entry.slice(0, separator); + const value = entry.slice(separator + 1); + prefixes[key] = value; + } + return prefixes; +} diff --git a/packages/cli-utils/lib/service.ts b/packages/cli-utils/lib/service.ts new file mode 100644 index 00000000..d9538437 --- /dev/null +++ b/packages/cli-utils/lib/service.ts @@ -0,0 +1,68 @@ +/* eslint-disable import/no-nodejs-modules */ +import { createInterface } from 'node:readline'; +import type { Readable } from 'node:stream'; + +export interface JsonlResponse { + readonly id?: number | string; + readonly ok: boolean; + readonly result?: unknown; + readonly error?: { readonly message: string }; +} + +export interface JsonlServiceStreams { + readonly input?: Readable; + readonly output?: { write: (chunk: string) => void }; +} + +/** + * Run a JSONL request/response service over stdio (or injected streams). + * + * Each line of input must be a JSON object. The handler is called with the + * parsed object and its return value is sent back as a JSONL response. + * Malformed JSON and handler errors are reported as error responses. + * + * @param handler - Async or sync function that processes a single request. + * @param streams - Optional stream overrides for testing (defaults to process.stdin / process.stdout). + */ +export async function runJsonlService( + handler: (request: unknown) => Promise | unknown, + streams: JsonlServiceStreams = {}, +): Promise { + const input = streams.input ?? process.stdin; + const output = streams.output ?? process.stdout; + + const rl = createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY }); + for await (const lineRaw of rl) { + const line = lineRaw.trim(); + if (line.length === 0) { + continue; + } + let request: unknown; + try { + request = JSON.parse(line); + } catch { + output.write(`${JSON.stringify({ ok: false, error: { message: 'Invalid JSON request' }})}\n`); + continue; + } + + const id = + typeof request === 'object' && request !== null ? (> request).id : undefined; + try { + const result = await handler(request); + const response: JsonlResponse = { + id: typeof id === 'number' || typeof id === 'string' ? id : undefined, + ok: true, + result, + }; + output.write(`${JSON.stringify(response)}\n`); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + const response: JsonlResponse = { + id: typeof id === 'number' || typeof id === 'string' ? id : undefined, + ok: false, + error: { message }, + }; + output.write(`${JSON.stringify(response)}\n`); + } + } +} diff --git a/packages/cli-utils/package.json b/packages/cli-utils/package.json new file mode 100644 index 00000000..6cbb87ee --- /dev/null +++ b/packages/cli-utils/package.json @@ -0,0 +1,46 @@ +{ + "name": "@traqula/cli-utils", + "type": "module", + "version": "1.0.4", + "description": "Shared CLI utilities for Traqula command-line tools", + "lsd:module": true, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/comunica/traqula.git", + "directory": "packages/cli-utils" + }, + "bugs": { + "url": "https://github.com/comunica/traqula/issues" + }, + "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/esm/lib/index.d.ts", + "import": "./dist/esm/lib/index.js", + "require": "./dist/cjs/lib/index.js" + } + }, + "main": "dist/esm/lib/index.js", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist/*/lib/**/*.d.ts", + "dist/*/lib/**/*.js", + "dist/*/lib/**/*.js.map", + "dist/cjs/package.json" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "scripts": { + "build": "yarn build:ts && yarn build:cjs", + "build:ts": "node \"../../node_modules/typescript/bin/tsc\" -b", + "build:cjs": "node \"../../node_modules/typescript/bin/tsc\" -b tsconfig.cjs.json" + }, + "devDependencies": { + "@types/node": "^24.3.1" + } +} diff --git a/packages/cli-utils/test/index.test.ts b/packages/cli-utils/test/index.test.ts new file mode 100644 index 00000000..0d05cdf1 --- /dev/null +++ b/packages/cli-utils/test/index.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { parsePrefixMappings } from '../lib/index.js'; + +describe('parsePrefixMappings', () => { + it('parses valid prefix=iri mappings', () => { + const result = parsePrefixMappings([ 'rdf=http://www.w3.org/1999/02/22-rdf-syntax-ns#' ]); + expect(result).toEqual({ rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#' }); + }); + + it('parses multiple mappings', () => { + const result = parsePrefixMappings([ + 'rdf=http://www.w3.org/1999/02/22-rdf-syntax-ns#', + 'rdfs=http://www.w3.org/2000/01/rdf-schema#', + ]); + expect(result).toEqual({ + rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', + rdfs: 'http://www.w3.org/2000/01/rdf-schema#', + }); + }); + + it('returns empty object for empty input', () => { + const result = parsePrefixMappings([]); + expect(result).toEqual({}); + }); + + it('throws on missing separator', () => { + expect(() => parsePrefixMappings([ 'invalid' ])).toThrow('Invalid prefix mapping \'invalid\''); + }); + + it('throws on empty prefix', () => { + expect(() => parsePrefixMappings([ '=http://example.org/' ])).toThrow('Invalid prefix mapping \'=http://example.org/\''); + }); + + it('throws on empty value', () => { + expect(() => parsePrefixMappings([ 'prefix=' ])).toThrow('Invalid prefix mapping \'prefix=\''); + }); + + it('handles IRIs containing = characters', () => { + const result = parsePrefixMappings([ 'ex=http://example.org/path?key=value' ]); + expect(result).toEqual({ ex: 'http://example.org/path?key=value' }); + }); +}); diff --git a/packages/cli-utils/test/io.test.ts b/packages/cli-utils/test/io.test.ts new file mode 100644 index 00000000..c3344c20 --- /dev/null +++ b/packages/cli-utils/test/io.test.ts @@ -0,0 +1,111 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, it, expect } from 'vitest'; +import { readTextInput, writeTextOutput, readJsonInput, writeJsonOutput } from '../lib/index.js'; + +const tmpDir = os.tmpdir(); + +async function withTempFile( + content: string, + fn: (filePath: string) => Promise, +): Promise { + const filePath = path.join(tmpDir, `traqula-test-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`); + await fs.writeFile(filePath, content, 'utf8'); + try { + await fn(filePath); + } finally { + await fs.unlink(filePath).catch(() => {}); + } +} + +async function withEmptyTempFile(fn: (filePath: string) => Promise): Promise { + const filePath = path.join(tmpDir, `traqula-out-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`); + try { + await fn(filePath); + } finally { + await fs.unlink(filePath).catch(() => {}); + } +} + +describe('readTextInput', () => { + it('reads content from a file', async() => { + await withTempFile('hello world', async(filePath) => { + const result = await readTextInput(filePath); + expect(result).toBe('hello world'); + }); + }); + + it('reads UTF-8 content including special characters', async() => { + const text = 'prefix rdf: '; + await withTempFile(text, async(filePath) => { + expect(await readTextInput(filePath)).toBe(text); + }); + }); + + it('throws when file does not exist', async() => { + await expect(readTextInput('/does/not/exist.txt')).rejects.toThrow(); + }); +}); + +describe('writeTextOutput', () => { + it('writes content to a file', async() => { + await withEmptyTempFile(async(filePath) => { + await writeTextOutput('SELECT * WHERE { ?s ?p ?o }', filePath); + const content = await fs.readFile(filePath, 'utf8'); + expect(content).toBe('SELECT * WHERE { ?s ?p ?o }'); + }); + }); + + it('writes content that already ends with newline unchanged', async() => { + await withEmptyTempFile(async(filePath) => { + await writeTextOutput('line\n', filePath); + const content = await fs.readFile(filePath, 'utf8'); + expect(content).toBe('line\n'); + }); + }); + + it('throws when output directory does not exist', async() => { + await expect(writeTextOutput('data', '/no/such/dir/out.txt')).rejects.toThrow(); + }); +}); + +describe('readJsonInput', () => { + it('parses JSON from file', async() => { + await withTempFile('{"key":"value"}', async(filePath) => { + const result = await readJsonInput<{ key: string }>(filePath); + expect(result).toEqual({ key: 'value' }); + }); + }); + + it('parses an array from file', async() => { + await withTempFile('[1,2,3]', async(filePath) => { + const result = await readJsonInput(filePath); + expect(result).toEqual([ 1, 2, 3 ]); + }); + }); + + it('throws on invalid JSON', async() => { + await withTempFile('{invalid}', async(filePath) => { + await expect(readJsonInput(filePath)).rejects.toThrow(); + }); + }); +}); + +describe('writeJsonOutput', () => { + it('writes compact JSON to file', async() => { + await withEmptyTempFile(async(filePath) => { + await writeJsonOutput({ a: 1 }, false, filePath); + const content = await fs.readFile(filePath, 'utf8'); + expect(content).toBe('{"a":1}\n'); + }); + }); + + it('writes pretty-printed JSON to file', async() => { + await withEmptyTempFile(async(filePath) => { + await writeJsonOutput({ a: 1 }, true, filePath); + const content = await fs.readFile(filePath, 'utf8'); + expect(content).toBe('{\n "a": 1\n}\n'); + }); + }); +}); diff --git a/packages/cli-utils/test/service.test.ts b/packages/cli-utils/test/service.test.ts new file mode 100644 index 00000000..fb181a60 --- /dev/null +++ b/packages/cli-utils/test/service.test.ts @@ -0,0 +1,160 @@ +import { Readable } from 'node:stream'; +import { describe, it, expect } from 'vitest'; +import type { JsonlResponse } from '../lib/index.js'; +import { runJsonlService } from '../lib/index.js'; + +function makeStream(lines: string[]): Readable { + return Readable.from(`${lines.join('\n')}\n`); +} + +function makeOutput(): { write: (chunk: string) => void; lines: () => JsonlResponse[] } { + const chunks: string[] = []; + return { + write(chunk: string) { + chunks.push(chunk); + }, + lines() { + return chunks.join('').split('\n').filter(l => l.length > 0).map(l => JSON.parse(l)); + }, + }; +} + +describe('runJsonlService', () => { + it('processes a single valid request', async() => { + const input = makeStream([ '{"id":"1","value":42}' ]); + const output = makeOutput(); + + await runJsonlService( + async(req: unknown) => ({ echo: (<{ value: number }> req).value }), + { input, output }, + ); + + const lines = output.lines(); + expect(lines).toHaveLength(1); + expect(lines[0]).toEqual({ id: '1', ok: true, result: { echo: 42 }}); + }); + + it('processes multiple requests in order', async() => { + const input = makeStream([ + '{"id":1,"n":10}', + '{"id":2,"n":20}', + '{"id":3,"n":30}', + ]); + const output = makeOutput(); + + await runJsonlService( + async(req: unknown) => (<{ n: number }> req).n * 2, + { input, output }, + ); + + const lines = output.lines(); + expect(lines).toHaveLength(3); + expect(lines[0]).toEqual({ id: 1, ok: true, result: 20 }); + expect(lines[1]).toEqual({ id: 2, ok: true, result: 40 }); + expect(lines[2]).toEqual({ id: 3, ok: true, result: 60 }); + }); + + it('returns error response for invalid JSON lines', async() => { + const input = makeStream([ 'not valid json' ]); + const output = makeOutput(); + + await runJsonlService(async() => 'should not reach', { input, output }); + + const lines = output.lines(); + expect(lines).toHaveLength(1); + expect(lines[0]).toEqual({ ok: false, error: { message: 'Invalid JSON request' }}); + }); + + it('returns error response when handler throws', async() => { + const input = makeStream([ '{"id":"err-1"}' ]); + const output = makeOutput(); + + await runJsonlService( + async() => { + throw new Error('processing failed'); + }, + { input, output }, + ); + + const lines = output.lines(); + expect(lines).toHaveLength(1); + expect(lines[0]).toEqual({ id: 'err-1', ok: false, error: { message: 'processing failed' }}); + }); + + it('includes id from request in error response', async() => { + const input = makeStream([ '{"id":"abc","bad":true}' ]); + const output = makeOutput(); + + await runJsonlService( + async() => { + throw new Error('boom'); + }, + { input, output }, + ); + + expect(output.lines()[0].id).toBe('abc'); + }); + + it('omits id from response when request has no id', async() => { + const input = makeStream([ '{"value":1}' ]); + const output = makeOutput(); + + await runJsonlService(async() => 'ok', { input, output }); + + const response = output.lines()[0]; + expect(response.id).toBeUndefined(); + expect(response.ok).toBe(true); + }); + + it('supports numeric ids', async() => { + const input = makeStream([ '{"id":99}' ]); + const output = makeOutput(); + + await runJsonlService(async() => 'done', { input, output }); + + expect(output.lines()[0].id).toBe(99); + }); + + it('skips blank lines', async() => { + const input = makeStream([ '', ' ', '{"id":"1"}' ]); + const output = makeOutput(); + + await runJsonlService(async() => 'ok', { input, output }); + + expect(output.lines()).toHaveLength(1); + }); + + it('handles a non-Error thrown value', async() => { + const input = makeStream([ '{}' ]); + const output = makeOutput(); + + const nonError: unknown = 'string error'; + await runJsonlService(async() => { + throw nonError; + }, { input, output }); + + expect(output.lines()[0]).toEqual({ ok: false, error: { message: 'Unknown error' }}); + }); + + it('handles a mix of valid and invalid lines', async() => { + const input = makeStream([ + '{"id":"1"}', + 'bad json', + '{"id":"2"}', + ]); + const output = makeOutput(); + + await runJsonlService(async() => 'result', { input, output }); + + const lines = output.lines(); + expect(lines).toHaveLength(3); + expect(lines[0]).toMatchObject({ id: '1', ok: true }); + expect(lines[1]).toMatchObject({ ok: false }); + expect(lines[2]).toMatchObject({ id: '2', ok: true }); + }); + + it('defaults to process.stdin and process.stdout when no streams provided', () => { + // Verify the function signature accepts no streams argument + expect(() => runJsonlService(async() => {})).not.toThrow(); + }); +}); diff --git a/packages/cli-utils/tsconfig.cjs.json b/packages/cli-utils/tsconfig.cjs.json new file mode 100644 index 00000000..47b5f1b9 --- /dev/null +++ b/packages/cli-utils/tsconfig.cjs.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "module": "CommonJS", + "moduleResolution": "node", + "declaration": false, + "outDir": "dist/cjs", + "skipLibCheck": true + } +} diff --git a/packages/cli-utils/tsconfig.json b/packages/cli-utils/tsconfig.json new file mode 100644 index 00000000..7727e01a --- /dev/null +++ b/packages/cli-utils/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": ".", + "types": ["node"], + "outDir": "dist/esm" + }, + "include": ["lib/**/*.ts"], + "exclude": ["dist/**"] +} diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json index 279d7786..46e6104a 100644 --- a/tsconfig.cjs.json +++ b/tsconfig.cjs.json @@ -3,6 +3,7 @@ "references": [ { "path": "packages/algebra-transformations-1-1/tsconfig.cjs.json" }, { "path": "packages/algebra-transformations-1-2/tsconfig.cjs.json" }, + { "path": "packages/cli-utils/tsconfig.cjs.json" }, { "path": "packages/core/tsconfig.cjs.json" }, { "path": "packages/rules-sparql-1-1/tsconfig.cjs.json" }, { "path": "packages/rules-sparql-1-1-adjust/tsconfig.cjs.json" }, diff --git a/tsconfig.json b/tsconfig.json index 54954105..b1ff450e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ { "path": "packages/algebra-transformations-1-1" }, { "path": "packages/algebra-transformations-1-2" }, { "path": "packages/chevrotain" }, + { "path": "packages/cli-utils" }, { "path": "packages/core" }, { "path": "packages/rules-sparql-1-1" }, { "path": "packages/rules-sparql-1-1-adjust" }, diff --git a/yarn.lock b/yarn.lock index cc07a1f5..e0e1878f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1678,17 +1678,17 @@ "@rdfjs/types" "*" "@types/node" "*" -"@types/node@*": - version "24.3.1" - resolved "https://registry.yarnpkg.com/@types/node/-/node-24.3.1.tgz#b0a3fb2afed0ef98e8d7f06d46ef6349047709f3" - integrity sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g== +"@types/node@*", "@types/node@^24.3.1": + version "24.12.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-24.12.0.tgz#6222e028210e5322e4f4f6767f8d88e5ea3b33d2" + integrity sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ== dependencies: - undici-types "~7.10.0" + undici-types "~7.16.0" "@types/node@^18.0.0": - version "18.19.124" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.124.tgz#6f49e4fab8274910691a900e8a14316cbf3c7a31" - integrity sha512-hY4YWZFLs3ku6D2Gqo3RchTd9VRCcrjqp/I0mmohYeUVA5Y8eCXKJEasHxLAJVZRJuQogfd1GiJ9lgogBgKeuQ== + version "18.19.130" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.130.tgz#da4c6324793a79defb7a62cba3947ec5add00d59" + integrity sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg== dependencies: undici-types "~5.26.4" @@ -2823,6 +2823,11 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" +commander@^14.0.3: + version "14.0.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-14.0.3.tgz#425d79b48f9af82fcd9e4fc1ea8af6c5ec07bbc2" + integrity sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw== + comment-parser@1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/comment-parser/-/comment-parser-1.4.1.tgz#bdafead37961ac079be11eb7ec65c4d021eaf9cc" @@ -8273,10 +8278,10 @@ undici-types@~5.26.4: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== -undici-types@~7.10.0: - version "7.10.0" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.10.0.tgz#4ac2e058ce56b462b056e629cc6a02393d3ff350" - integrity sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag== +undici-types@~7.16.0: + version "7.16.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46" + integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== unique-filename@^4.0.0: version "4.0.0"