From 4cf619e9a33b40d62280964b496d670d8224275d Mon Sep 17 00:00:00 2001 From: Jitse De Smet Date: Thu, 26 Mar 2026 17:34:42 +0100 Subject: [PATCH 1/5] Init --- engines/algebra-sparql-1-1/README.md | 28 +++ .../bin/traqula-algebra-sparql-1-1.ts | 100 ++++++++ engines/algebra-sparql-1-1/lib/cli.ts | 40 ++++ engines/algebra-sparql-1-1/lib/index.ts | 1 + engines/algebra-sparql-1-1/package.json | 10 + engines/algebra-sparql-1-1/test/cli.test.ts | 27 +++ engines/algebra-sparql-1-1/tsconfig.json | 4 +- engines/generator-sparql-1-1/README.md | 28 +++ .../bin/traqula-generator-sparql-1-1.ts | 96 ++++++++ engines/generator-sparql-1-1/lib/cli.ts | 40 ++++ engines/generator-sparql-1-1/lib/index.ts | 2 + engines/generator-sparql-1-1/package.json | 7 + engines/generator-sparql-1-1/test/cli.test.ts | 15 ++ engines/generator-sparql-1-1/tsconfig.json | 4 +- engines/parser-sparql-1-1/README.md | 28 +++ .../bin/traqula-parser-sparql-1-1.ts | 92 ++++++++ engines/parser-sparql-1-1/lib/cli.ts | 42 ++++ engines/parser-sparql-1-1/lib/index.ts | 1 + engines/parser-sparql-1-1/package.json | 7 + engines/parser-sparql-1-1/test/cli.test.ts | 24 ++ engines/parser-sparql-1-1/tsconfig.json | 4 +- packages/cli-utils/lib/index.ts | 215 ++++++++++++++++++ packages/cli-utils/package.json | 46 ++++ packages/cli-utils/tsconfig.cjs.json | 11 + packages/cli-utils/tsconfig.json | 11 + tsconfig.cjs.json | 1 + tsconfig.json | 1 + 27 files changed, 882 insertions(+), 3 deletions(-) create mode 100644 engines/algebra-sparql-1-1/bin/traqula-algebra-sparql-1-1.ts create mode 100644 engines/algebra-sparql-1-1/lib/cli.ts create mode 100644 engines/algebra-sparql-1-1/test/cli.test.ts create mode 100644 engines/generator-sparql-1-1/bin/traqula-generator-sparql-1-1.ts create mode 100644 engines/generator-sparql-1-1/lib/cli.ts create mode 100644 engines/generator-sparql-1-1/test/cli.test.ts create mode 100644 engines/parser-sparql-1-1/bin/traqula-parser-sparql-1-1.ts create mode 100644 engines/parser-sparql-1-1/lib/cli.ts create mode 100644 engines/parser-sparql-1-1/test/cli.test.ts create mode 100644 packages/cli-utils/lib/index.ts create mode 100644 packages/cli-utils/package.json create mode 100644 packages/cli-utils/tsconfig.cjs.json create mode 100644 packages/cli-utils/tsconfig.json diff --git a/engines/algebra-sparql-1-1/README.md b/engines/algebra-sparql-1-1/README.md index 5c04c971..ebad2a70 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":{...},"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..eb862a94 --- /dev/null +++ b/engines/algebra-sparql-1-1/bin/traqula-algebra-sparql-1-1.ts @@ -0,0 +1,100 @@ +#!/usr/bin/env node +import type { ContextConfigs } from '@traqula/algebra-transformations-1-1'; +import type { Algebra } from '@traqula/algebra-transformations-1-1'; +import { + getFlagAsBoolean, + getFlagAsString, + getFlagAsStrings, + parseCliArgs, + parsePrefixMappings, + readJsonInput, + runJsonlService, + writeJsonOutput, +} from '@traqula/cli-utils'; +import type { SparqlQuery } from '@traqula/rules-sparql-1-1'; +import { createAlgebraCliRuntime, handleAlgebraCliRequest } from '../lib/cli.js'; + +function printHelp(): void { + process.stdout.write(`Usage: traqula-algebra-sparql-1-1 [options]\n\n` + + `Translate between Traqula SPARQL AST JSON and SPARQL algebra JSON.\n\n` + + `Options:\n` + + ` -i, --input Read JSON input from file (defaults to stdin)\n` + + ` -o, --output Write JSON output to file (defaults to stdout)\n` + + ` --from Input format (default: ast)\n` + + ` --quads toAlgebra: convert patterns to quads\n` + + ` --blank-to-variable toAlgebra: rewrite blank nodes to variables\n` + + ` --base-iri toAlgebra: base IRI for relative resolution\n` + + ` --prefix toAlgebra: predefined prefixes (repeatable)\n` + + ` --pretty Pretty-print JSON output\n` + + ` --service [jsonl] Run JSONL service mode over stdio\n` + + ` -h, --help Show this help text\n\n` + + `Service request format:\n` + + ` {"id":"1","mode":"toAlgebra","input":{...},"options":{"quads":false}}\n`); +} + +function createToAlgebraOptions(args: ReturnType): ContextConfigs { + const prefixes = parsePrefixMappings(getFlagAsStrings(args, 'prefix')); + const options: ContextConfigs = { + quads: getFlagAsBoolean(args, 'quads'), + blankToVariable: getFlagAsBoolean(args, 'blank-to-variable'), + baseIRI: getFlagAsString(args, 'base-iri'), + prefixes, + }; + return options; +} + +function parseMode(args: ReturnType): 'toAlgebra' | 'toAst' { + const inputFormat = getFlagAsString(args, 'from'); + if (!inputFormat || inputFormat === 'ast') { + return 'toAlgebra'; + } + if (inputFormat === 'algebra') { + return 'toAst'; + } + throw new Error(`Invalid value for --from: ${inputFormat}`); +} + +async function run(): Promise { + const args = parseCliArgs(process.argv.slice(2)); + if (getFlagAsBoolean(args, 'help', 'h')) { + printHelp(); + return; + } + + const runtime = createAlgebraCliRuntime(); + if (getFlagAsBoolean(args, 'service')) { + await runJsonlService((request: unknown) => { + if (request === null || typeof request !== 'object') { + throw new Error('Service request must be a JSON object'); + } + const data = request as { mode?: unknown; input?: unknown; options?: unknown }; + 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'); + } + return handleAlgebraCliRequest(runtime, { + mode: data.mode, + input: data.input as SparqlQuery | Algebra.Operation, + options: data.options as ContextConfigs | undefined, + }); + }); + return; + } + + const mode = parseMode(args); + const input = await readJsonInput(getFlagAsString(args, 'input', 'i')); + const output = handleAlgebraCliRequest(runtime, { + mode, + input, + options: mode === 'toAlgebra' ? createToAlgebraOptions(args) : undefined, + }); + + await writeJsonOutput(output, getFlagAsBoolean(args, 'pretty'), getFlagAsString(args, 'output', 'o')); +} + +run().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..ef3c5156 --- /dev/null +++ b/engines/algebra-sparql-1-1/lib/cli.ts @@ -0,0 +1,40 @@ +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 interface AlgebraCliRequest { + readonly mode: 'toAlgebra' | 'toAst'; + readonly input: SparqlQuery | 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 as SparqlQuery, + options.quads, + options.blankToVariable, + ); + return algebraUtils.objectify(operation); + } + + const context = createAstContext(); + return runtime.toAstTransformer.algToSparql(context, request.input as Algebra.Operation); +} 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..ec8cd1c9 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,6 +49,7 @@ }, "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" }, 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..dfb57a64 --- /dev/null +++ b/engines/algebra-sparql-1-1/test/cli.test.ts @@ -0,0 +1,27 @@ +import type { Algebra } from '@traqula/algebra-transformations-1-1'; +import { Parser } from '@traqula/parser-sparql-1-1'; +import { describe, it } from 'vitest'; +import { createAlgebraCliRuntime, handleAlgebraCliRequest } from '../lib/cli.js'; + +describe('algebra CLI runtime', () => { + it('converts AST to algebra and back', ({ expect }) => { + const parser = new Parser(); + const runtime = createAlgebraCliRuntime(); + const ast = parser.parse('SELECT * WHERE { ?s ?p ?o }'); + + const algebra = handleAlgebraCliRequest(runtime, { + mode: 'toAlgebra', + input: ast, + }) as Algebra.Operation; + + expect(algebra).toHaveProperty('type'); + + const astAgain = handleAlgebraCliRequest(runtime, { + mode: 'toAst', + input: algebra, + }) as { where?: unknown; loc?: unknown }; + + expect(astAgain.where).toBeDefined(); + expect(astAgain.loc).toBeDefined(); + }); +}); 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..e5b77080 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":{...},"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..a2ab66a1 --- /dev/null +++ b/engines/generator-sparql-1-1/bin/traqula-generator-sparql-1-1.ts @@ -0,0 +1,96 @@ +#!/usr/bin/env node +import { + getFlagAsBoolean, + getFlagAsString, + parseCliArgs, + 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 { createGeneratorCliRuntime, handleGeneratorCliRequest } from '../lib/cli.js'; + +function printHelp(): void { + process.stdout.write(`Usage: traqula-generator-sparql-1-1 [options]\n\n` + + `Generate SPARQL 1.1 text from a Traqula AST JSON input.\n\n` + + `Options:\n` + + ` -i, --input Read AST JSON from file (defaults to stdin)\n` + + ` -o, --output Write generated SPARQL to file (defaults to stdout)\n` + + ` --path Treat input AST as a SPARQL path\n` + + ` --compact Disable pretty printing and newlines\n` + + ` --indent Configure indentation width\n` + + ` --newline-alt Separator used when compact mode disables newlines\n` + + ` --service [jsonl] Run JSONL service mode over stdio\n` + + ` -h, --help Show this help text\n\n` + + `Service request format:\n` + + ` {"id":"1","ast":{...},"path":false,"context":{...}}\n`); +} + +function parsePositiveInt(input: string, option: string): number { + const parsed = Number.parseInt(input, 10); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error(`Invalid value for --${option}: ${input}`); + } + return parsed; +} + +function createDefaultContext(args: ReturnType): Partial { + const context: Partial = {}; + + const indent = getFlagAsString(args, 'indent'); + if (indent !== undefined) { + context.indentInc = parsePositiveInt(indent, 'indent'); + } + + const newlineAlt = getFlagAsString(args, 'newline-alt'); + if (newlineAlt !== undefined) { + context[traqulaNewlineAlternative] = newlineAlt; + } + + if (getFlagAsBoolean(args, 'compact')) { + context[traqulaIndentation] = -1; + context.indentInc = 0; + } + + return context; +} + +async function run(): Promise { + const args = parseCliArgs(process.argv.slice(2)); + if (getFlagAsBoolean(args, 'help', 'h')) { + printHelp(); + return; + } + + const runtime = createGeneratorCliRuntime(createDefaultContext(args)); + if (getFlagAsBoolean(args, 'service')) { + await runJsonlService((request: unknown) => { + if (request === null || typeof request !== 'object') { + throw new Error('Service request must be a JSON object'); + } + const data = request as { ast?: unknown; path?: unknown; context?: unknown }; + if (data.ast === undefined) { + throw new Error('Missing property: ast'); + } + return handleGeneratorCliRequest(runtime, { + ast: data.ast as Query | Update | Path, + path: Boolean(data.path), + context: data.context as Partial | undefined, + }); + }); + return; + } + + const ast = await readJsonInput(getFlagAsString(args, 'input', 'i')); + const output = handleGeneratorCliRequest(runtime, { + ast, + path: getFlagAsBoolean(args, 'path'), + }); + await writeTextOutput(output, getFlagAsString(args, 'output', 'o')); +} + +run().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..e5d177c1 --- /dev/null +++ b/engines/generator-sparql-1-1/lib/cli.ts @@ -0,0 +1,40 @@ +import type { SparqlGeneratorContext, Path, Query, Update } from '@traqula/rules-sparql-1-1'; +import { Generator } from './index.js'; + +export interface GeneratorCliRequest { + readonly ast: Query | Update | Path; + readonly path?: boolean; + 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 as Path, context) : + runtime.generator.generate(request.ast as Query | Update, 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..12b8d665 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,6 +47,7 @@ "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" }, 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..9486be64 --- /dev/null +++ b/engines/generator-sparql-1-1/test/cli.test.ts @@ -0,0 +1,15 @@ +import { Parser } from '@traqula/parser-sparql-1-1'; +import { describe, it } from 'vitest'; +import { createGeneratorCliRuntime, handleGeneratorCliRequest } from '../lib/cli.js'; + +describe('generator CLI runtime', () => { + it('generates a query from AST', ({ expect }) => { + const parser = new Parser(); + 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'); + }); +}); 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..9b41d9e4 --- /dev/null +++ b/engines/parser-sparql-1-1/bin/traqula-parser-sparql-1-1.ts @@ -0,0 +1,92 @@ +#!/usr/bin/env node +import { + getFlagAsBoolean, + getFlagAsString, + getFlagAsStrings, + parseCliArgs, + parsePrefixMappings, + readTextInput, + runJsonlService, + writeJsonOutput, +} from '@traqula/cli-utils'; +import type { SparqlContext } from '@traqula/rules-sparql-1-1'; +import { createParserCliRuntime, handleParserCliRequest } from '../lib/cli.js'; + +function printHelp(): void { + process.stdout.write(`Usage: traqula-parser-sparql-1-1 [options]\n\n` + + `Parse SPARQL 1.1 input into a Traqula AST (JSON).\n\n` + + `Options:\n` + + ` -i, --input Read query text from file (defaults to stdin)\n` + + ` -o, --output Write JSON output to file (defaults to stdout)\n` + + ` --path Parse input as a SPARQL path expression\n` + + ` --base-iri Set default base IRI\n` + + ` --prefix Set default prefix mapping (repeatable)\n` + + ` --skip-validation Skip parser validation checks\n` + + ` --allow-vars Enable variable parsing mode\n` + + ` --allow-blank-nodes Enable blank node creation mode\n` + + ` --pretty Pretty-print JSON output\n` + + ` --service [jsonl] Run JSONL service mode over stdio\n` + + ` -h, --help Show this help text\n\n` + + `Service request format:\n` + + ` {"id":"1","query":"SELECT * WHERE { ?s ?p ?o }","path":false,"context":{...}}\n`); +} + +function createDefaultContext(args: ReturnType): Partial { + const parseMode = new Set(); + if (getFlagAsBoolean(args, 'allow-vars')) { + parseMode.add('canParseVars'); + } + if (getFlagAsBoolean(args, 'allow-blank-nodes')) { + parseMode.add('canCreateBlankNodes'); + } + + const prefixes = parsePrefixMappings(getFlagAsStrings(args, 'prefix')); + const context: Partial = { + baseIRI: getFlagAsString(args, 'base-iri'), + skipValidation: getFlagAsBoolean(args, 'skip-validation'), + prefixes, + }; + if (parseMode.size > 0) { + context.parseMode = parseMode; + } + return context; +} + +async function run(): Promise { + const args = parseCliArgs(process.argv.slice(2)); + if (getFlagAsBoolean(args, 'help', 'h')) { + printHelp(); + return; + } + + const runtime = createParserCliRuntime(createDefaultContext(args)); + if (getFlagAsBoolean(args, '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(getFlagAsString(args, 'input', 'i')); + const output = handleParserCliRequest(runtime, { + query, + path: getFlagAsBoolean(args, 'path'), + }); + await writeJsonOutput(output, getFlagAsBoolean(args, 'pretty'), getFlagAsString(args, 'output', 'o')); +} + +run().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..3a5ceca6 --- /dev/null +++ b/engines/parser-sparql-1-1/lib/cli.ts @@ -0,0 +1,42 @@ +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; + return { + ...defaults, + ...override, + prefixes: { ...defaults.prefixes, ...override.prefixes }, + parseMode: parseMode ? new Set(parseMode) : undefined, + }; +} 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..c158ac9b 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,6 +56,7 @@ "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" }, 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..a01d24f9 --- /dev/null +++ b/engines/parser-sparql-1-1/test/cli.test.ts @@ -0,0 +1,24 @@ +import { describe, it } from 'vitest'; +import { createParserCliRuntime, handleParserCliRequest } from '../lib/cli.js'; + +describe('parser CLI runtime', () => { + it('parses a query', ({ expect }) => { + const runtime = createParserCliRuntime(); + const ast = handleParserCliRequest(runtime, { + query: 'SELECT * WHERE { ?s ?p ?o }', + }) as { loc?: unknown; where?: unknown }; + + expect(ast.loc).toBeDefined(); + expect(ast.where).toBeDefined(); + }); + + it('parses a path expression', ({ expect }) => { + const runtime = createParserCliRuntime(); + const path = handleParserCliRequest(runtime, { + query: ' / ', + path: true, + }) as { loc?: unknown }; + + expect(path.loc).toBeDefined(); + }); +}); 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/packages/cli-utils/lib/index.ts b/packages/cli-utils/lib/index.ts new file mode 100644 index 00000000..e36e315d --- /dev/null +++ b/packages/cli-utils/lib/index.ts @@ -0,0 +1,215 @@ +import fs from 'node:fs/promises'; +import { createInterface } from 'node:readline'; + +export type CliArgValue = boolean | string | string[]; + +export interface ParsedCliArgs { + readonly positionals: string[]; + readonly flags: Readonly>; +} + +export function parseCliArgs(argv: readonly string[]): ParsedCliArgs { + const positionals: string[] = []; + const flags: Record = {}; + + let positionalOnly = false; + for (let index = 0; index < argv.length; index++) { + const value = argv[index]; + if (positionalOnly) { + positionals.push(value); + continue; + } + if (value === '--') { + positionalOnly = true; + continue; + } + if (!value.startsWith('-') || value === '-') { + positionals.push(value); + continue; + } + + if (value.startsWith('--')) { + const withNoPrefix = value.slice(2); + const equalIndex = withNoPrefix.indexOf('='); + const flag = equalIndex >= 0 ? withNoPrefix.slice(0, equalIndex) : withNoPrefix; + const inlineValue = equalIndex >= 0 ? withNoPrefix.slice(equalIndex + 1) : undefined; + if (flag.startsWith('no-')) { + flags[flag.slice(3)] = false; + continue; + } + if (inlineValue !== undefined) { + assignFlagValue(flags, flag, inlineValue); + continue; + } + const next = argv[index + 1]; + if (next !== undefined && (!next.startsWith('-') || next === '-')) { + assignFlagValue(flags, flag, next); + index++; + } else { + flags[flag] = true; + } + continue; + } + + const shortFlags = value.slice(1); + for (let shortIndex = 0; shortIndex < shortFlags.length; shortIndex++) { + const shortFlag = shortFlags[shortIndex]; + if (shortIndex < shortFlags.length - 1) { + flags[shortFlag] = true; + continue; + } + const next = argv[index + 1]; + if (next !== undefined && (!next.startsWith('-') || next === '-')) { + assignFlagValue(flags, shortFlag, next); + index++; + } else { + flags[shortFlag] = true; + } + } + } + + return { positionals, flags }; +} + +function assignFlagValue(flags: Record, key: string, value: string): void { + const current = flags[key]; + if (current === undefined || typeof current === 'boolean') { + flags[key] = value; + } else if (Array.isArray(current)) { + current.push(value); + } else { + flags[key] = [ current, value ]; + } +} + +export function getFlag(args: ParsedCliArgs, ...names: string[]): CliArgValue | undefined { + for (const name of names) { + const value = args.flags[name]; + if (value !== undefined) { + return value; + } + } + return undefined; +} + +export function getFlagAsString(args: ParsedCliArgs, ...names: string[]): string | undefined { + const flag = getFlag(args, ...names); + if (flag === undefined || typeof flag === 'boolean') { + return undefined; + } + return Array.isArray(flag) ? flag.at(-1) : flag; +} + +export function getFlagAsBoolean(args: ParsedCliArgs, ...names: string[]): boolean { + const flag = getFlag(args, ...names); + if (flag === undefined) { + return false; + } + if (typeof flag === 'boolean') { + return flag; + } + const value = Array.isArray(flag) ? flag.at(-1) : flag; + return value !== 'false' && value !== '0'; +} + +export function getFlagAsStrings(args: ParsedCliArgs, ...names: string[]): string[] { + const flag = getFlag(args, ...names); + if (flag === undefined || typeof flag === 'boolean') { + return []; + } + return Array.isArray(flag) ? flag : [ flag ]; +} + +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 as Buffer).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) as T; +} + +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); +} + +export interface JsonlResponse { + readonly id?: number | string; + readonly ok: boolean; + readonly result?: unknown; + readonly error?: { readonly message: string }; +} + +export async function runJsonlService( + handler: (request: unknown) => Promise | unknown, +): Promise { + const rl = createInterface({ input: process.stdin, 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 { + process.stdout.write(`${JSON.stringify({ ok: false, error: { message: 'Invalid JSON request' }})}\n`); + continue; + } + + const id = + typeof request === 'object' && request !== null ? (request as Record).id : undefined; + try { + const result = await handler(request); + const response: JsonlResponse = { + id: typeof id === 'number' || typeof id === 'string' ? id : undefined, + ok: true, + result, + }; + process.stdout.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 }, + }; + process.stdout.write(`${JSON.stringify(response)}\n`); + } + } +} + +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/package.json b/packages/cli-utils/package.json new file mode 100644 index 00000000..0f681b7a --- /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": "*" + } +} 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" }, From a55e6011b83043e6be19d956b863bc3431ec898e Mon Sep 17 00:00:00 2001 From: Jitse De Smet Date: Fri, 27 Mar 2026 09:39:02 +0100 Subject: [PATCH 2/5] fix lint --- engines/algebra-sparql-1-1/README.md | 2 +- .../bin/traqula-algebra-sparql-1-1.ts | 32 ++++++++++++------- engines/algebra-sparql-1-1/lib/cli.ts | 20 ++++++++---- engines/algebra-sparql-1-1/package.json | 1 + engines/algebra-sparql-1-1/test/cli.test.ts | 8 ++--- engines/generator-sparql-1-1/README.md | 2 +- .../bin/traqula-generator-sparql-1-1.ts | 21 +++++++----- engines/generator-sparql-1-1/lib/cli.ts | 20 ++++++++---- engines/generator-sparql-1-1/package.json | 3 +- engines/parser-sparql-1-1/package.json | 1 + engines/parser-sparql-1-1/test/cli.test.ts | 8 ++--- packages/cli-utils/lib/index.ts | 7 ++-- yarn.lock | 12 +++++++ 13 files changed, 90 insertions(+), 47 deletions(-) diff --git a/engines/algebra-sparql-1-1/README.md b/engines/algebra-sparql-1-1/README.md index ebad2a70..e06c4a96 100644 --- a/engines/algebra-sparql-1-1/README.md +++ b/engines/algebra-sparql-1-1/README.md @@ -279,5 +279,5 @@ traqula-algebra-sparql-1-1 --service Request example: ```json -{"id":"1","mode":"toAlgebra","input":{...},"options":{"quads":false,"blankToVariable":false}} +{ "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 index eb862a94..479360c9 100644 --- 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 @@ -1,6 +1,5 @@ #!/usr/bin/env node -import type { ContextConfigs } from '@traqula/algebra-transformations-1-1'; -import type { Algebra } from '@traqula/algebra-transformations-1-1'; +import type { Algebra, ContextConfigs } from '@traqula/algebra-transformations-1-1'; import { getFlagAsBoolean, getFlagAsString, @@ -67,17 +66,23 @@ async function run(): Promise { if (request === null || typeof request !== 'object') { throw new Error('Service request must be a JSON object'); } - const data = request as { mode?: unknown; input?: unknown; options?: unknown }; + 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: data.mode, - input: data.input as SparqlQuery | Algebra.Operation, - options: data.options as ContextConfigs | undefined, + mode: 'toAst', + input: data.input, }); }); return; @@ -85,11 +90,16 @@ async function run(): Promise { const mode = parseMode(args); const input = await readJsonInput(getFlagAsString(args, 'input', 'i')); - const output = handleAlgebraCliRequest(runtime, { - mode, - input, - options: mode === 'toAlgebra' ? createToAlgebraOptions(args) : undefined, - }); + const output = mode === 'toAlgebra' ? + handleAlgebraCliRequest(runtime, { + mode: 'toAlgebra', + input: input, + options: createToAlgebraOptions(args), + }) : + handleAlgebraCliRequest(runtime, { + mode: 'toAst', + input: input, + }); await writeJsonOutput(output, getFlagAsBoolean(args, 'pretty'), getFlagAsString(args, 'output', 'o')); } diff --git a/engines/algebra-sparql-1-1/lib/cli.ts b/engines/algebra-sparql-1-1/lib/cli.ts index ef3c5156..aafda44d 100644 --- a/engines/algebra-sparql-1-1/lib/cli.ts +++ b/engines/algebra-sparql-1-1/lib/cli.ts @@ -4,11 +4,17 @@ import type { SparqlQuery } from '@traqula/rules-sparql-1-1'; import { toAlgebra11Builder } from './toAlgebra.js'; import { toAst11Builder } from './toAst.js'; -export interface AlgebraCliRequest { - readonly mode: 'toAlgebra' | 'toAst'; - readonly input: SparqlQuery | Algebra.Operation; - readonly options?: ContextConfigs; -} +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; @@ -28,7 +34,7 @@ export function handleAlgebraCliRequest(runtime: AlgebraCliRuntime, request: Alg const context = createAlgebraContext(options); const operation = runtime.toAlgebraTransformer.translateQuery( context, - request.input as SparqlQuery, + request.input, options.quads, options.blankToVariable, ); @@ -36,5 +42,5 @@ export function handleAlgebraCliRequest(runtime: AlgebraCliRuntime, request: Alg } const context = createAstContext(); - return runtime.toAstTransformer.algToSparql(context, request.input as Algebra.Operation); + return runtime.toAstTransformer.algToSparql(context, request.input); } diff --git a/engines/algebra-sparql-1-1/package.json b/engines/algebra-sparql-1-1/package.json index ec8cd1c9..81856933 100644 --- a/engines/algebra-sparql-1-1/package.json +++ b/engines/algebra-sparql-1-1/package.json @@ -57,6 +57,7 @@ "@traqula/generator-sparql-1-1": "^1.0.4", "@traqula/parser-sparql-1-1": "^1.0.4", "@traqula/test-utils": "^1.0.4", + "@types/node": "^25.5.0", "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 index dfb57a64..aff63f02 100644 --- a/engines/algebra-sparql-1-1/test/cli.test.ts +++ b/engines/algebra-sparql-1-1/test/cli.test.ts @@ -9,17 +9,17 @@ describe('algebra CLI runtime', () => { const runtime = createAlgebraCliRuntime(); const ast = parser.parse('SELECT * WHERE { ?s ?p ?o }'); - const algebra = handleAlgebraCliRequest(runtime, { + const algebra = handleAlgebraCliRequest(runtime, { mode: 'toAlgebra', input: ast, - }) as Algebra.Operation; + }); expect(algebra).toHaveProperty('type'); - const astAgain = handleAlgebraCliRequest(runtime, { + const astAgain = <{ where?: unknown; loc?: unknown }> handleAlgebraCliRequest(runtime, { mode: 'toAst', input: algebra, - }) as { where?: unknown; loc?: unknown }; + }); expect(astAgain.where).toBeDefined(); expect(astAgain.loc).toBeDefined(); diff --git a/engines/generator-sparql-1-1/README.md b/engines/generator-sparql-1-1/README.md index e5b77080..d7fff59f 100644 --- a/engines/generator-sparql-1-1/README.md +++ b/engines/generator-sparql-1-1/README.md @@ -108,5 +108,5 @@ traqula-generator-sparql-1-1 --service Request example: ```json -{"id":"1","ast":{...},"path":false,"context":{}} +{ "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 index a2ab66a1..cc40c193 100644 --- 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 @@ -69,24 +69,29 @@ async function run(): Promise { if (request === null || typeof request !== 'object') { throw new Error('Service request must be a JSON object'); } - const data = request as { ast?: unknown; path?: unknown; context?: unknown }; + 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 as Query | Update | Path, - path: Boolean(data.path), - context: data.context as Partial | undefined, + ast: data.ast, + context: | undefined> data.context, }); }); return; } const ast = await readJsonInput(getFlagAsString(args, 'input', 'i')); - const output = handleGeneratorCliRequest(runtime, { - ast, - path: getFlagAsBoolean(args, 'path'), - }); + const output = getFlagAsBoolean(args, 'path') ? + handleGeneratorCliRequest(runtime, { ast: ast, path: true }) : + handleGeneratorCliRequest(runtime, { ast: ast }); await writeTextOutput(output, getFlagAsString(args, 'output', 'o')); } diff --git a/engines/generator-sparql-1-1/lib/cli.ts b/engines/generator-sparql-1-1/lib/cli.ts index e5d177c1..26b8af68 100644 --- a/engines/generator-sparql-1-1/lib/cli.ts +++ b/engines/generator-sparql-1-1/lib/cli.ts @@ -1,11 +1,17 @@ import type { SparqlGeneratorContext, Path, Query, Update } from '@traqula/rules-sparql-1-1'; import { Generator } from './index.js'; -export interface GeneratorCliRequest { - readonly ast: Query | Update | Path; - readonly path?: boolean; - readonly context?: Partial; -} +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; @@ -22,8 +28,8 @@ export function createGeneratorCliRuntime(defaultContext: Partial { it('parses a query', ({ expect }) => { const runtime = createParserCliRuntime(); - const ast = handleParserCliRequest(runtime, { + const ast = <{ loc?: unknown; where?: unknown }> handleParserCliRequest(runtime, { query: 'SELECT * WHERE { ?s ?p ?o }', - }) as { loc?: unknown; where?: unknown }; + }); expect(ast.loc).toBeDefined(); expect(ast.where).toBeDefined(); @@ -14,10 +14,10 @@ describe('parser CLI runtime', () => { it('parses a path expression', ({ expect }) => { const runtime = createParserCliRuntime(); - const path = handleParserCliRequest(runtime, { + const path = <{ loc?: unknown }> handleParserCliRequest(runtime, { query: ' / ', path: true, - }) as { loc?: unknown }; + }); expect(path.loc).toBeDefined(); }); diff --git a/packages/cli-utils/lib/index.ts b/packages/cli-utils/lib/index.ts index e36e315d..b71f8ea3 100644 --- a/packages/cli-utils/lib/index.ts +++ b/packages/cli-utils/lib/index.ts @@ -1,3 +1,4 @@ +/* eslint-disable import/no-nodejs-modules */ import fs from 'node:fs/promises'; import { createInterface } from 'node:readline'; @@ -127,7 +128,7 @@ export async function readTextInput(filePath?: string): Promise { const chunks: string[] = []; for await (const chunk of process.stdin) { - chunks.push(typeof chunk === 'string' ? chunk : (chunk as Buffer).toString('utf8')); + chunks.push(typeof chunk === 'string' ? chunk : ( chunk).toString('utf8')); } return chunks.join(''); } @@ -145,7 +146,7 @@ export async function writeTextOutput(text: string, filePath?: string): Promise< export async function readJsonInput(filePath?: string): Promise { const text = await readTextInput(filePath); - return JSON.parse(text) as T; + return JSON.parse(text); } export async function writeJsonOutput(value: unknown, pretty: boolean, filePath?: string): Promise { @@ -179,7 +180,7 @@ export async function runJsonlService( } const id = - typeof request === 'object' && request !== null ? (request as Record).id : undefined; + typeof request === 'object' && request !== null ? (> request).id : undefined; try { const result = await handler(request); const response: JsonlResponse = { diff --git a/yarn.lock b/yarn.lock index cc07a1f5..966dfae3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1692,6 +1692,13 @@ dependencies: undici-types "~5.26.4" +"@types/node@^25.5.0": + version "25.5.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.5.0.tgz#5c99f37c443d9ccc4985866913f1ed364217da31" + integrity sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw== + dependencies: + undici-types "~7.18.0" + "@types/normalize-package-data@^2.4.0": version "2.4.4" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901" @@ -8278,6 +8285,11 @@ undici-types@~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.18.0: + version "7.18.2" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.18.2.tgz#29357a89e7b7ca4aef3bf0fd3fd0cd73884229e9" + integrity sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w== + unique-filename@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-4.0.0.tgz#a06534d370e7c977a939cd1d11f7f0ab8f1fed13" From bafd03196dff0f895ffb0209f56a3d0a5594ac4c Mon Sep 17 00:00:00 2001 From: Jitse De Smet Date: Fri, 27 Mar 2026 11:15:57 +0100 Subject: [PATCH 3/5] fix install problem --- engines/algebra-sparql-1-1/package.json | 2 +- engines/generator-sparql-1-1/package.json | 2 +- engines/parser-sparql-1-1/package.json | 2 +- package.json | 3 ++ packages/cli-utils/package.json | 2 +- yarn.lock | 42 +++++------------------ 6 files changed, 16 insertions(+), 37 deletions(-) diff --git a/engines/algebra-sparql-1-1/package.json b/engines/algebra-sparql-1-1/package.json index 81856933..adc5b969 100644 --- a/engines/algebra-sparql-1-1/package.json +++ b/engines/algebra-sparql-1-1/package.json @@ -57,7 +57,7 @@ "@traqula/generator-sparql-1-1": "^1.0.4", "@traqula/parser-sparql-1-1": "^1.0.4", "@traqula/test-utils": "^1.0.4", - "@types/node": "^25.5.0", + "@types/node": "^24.3.1", "sparqlalgebrajs": "^5.0.1", "sparqljs": "^3.7.3" } diff --git a/engines/generator-sparql-1-1/package.json b/engines/generator-sparql-1-1/package.json index 96de5f87..24e742d8 100644 --- a/engines/generator-sparql-1-1/package.json +++ b/engines/generator-sparql-1-1/package.json @@ -54,6 +54,6 @@ "devDependencies": { "@traqula/parser-sparql-1-1": "^1.0.4", "@traqula/test-utils": "^1.0.4", - "@types/node": "^25.5.0" + "@types/node": "^24.3.1" } } diff --git a/engines/parser-sparql-1-1/package.json b/engines/parser-sparql-1-1/package.json index c6745199..b5dc57f1 100644 --- a/engines/parser-sparql-1-1/package.json +++ b/engines/parser-sparql-1-1/package.json @@ -62,7 +62,7 @@ }, "devDependencies": { "@traqula/test-utils": "^1.0.4", - "@types/node": "^25.5.0", + "@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/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/package.json b/packages/cli-utils/package.json index 0f681b7a..6cbb87ee 100644 --- a/packages/cli-utils/package.json +++ b/packages/cli-utils/package.json @@ -41,6 +41,6 @@ "build:cjs": "node \"../../node_modules/typescript/bin/tsc\" -b tsconfig.cjs.json" }, "devDependencies": { - "@types/node": "*" + "@types/node": "^24.3.1" } } diff --git a/yarn.lock b/yarn.lock index 966dfae3..8f001a39 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1678,26 +1678,12 @@ "@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@^18.0.0", "@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" - -"@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== - dependencies: - undici-types "~5.26.4" - -"@types/node@^25.5.0": - version "25.5.0" - resolved "https://registry.yarnpkg.com/@types/node/-/node-25.5.0.tgz#5c99f37c443d9ccc4985866913f1ed364217da31" - integrity sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw== - dependencies: - undici-types "~7.18.0" + undici-types "~7.16.0" "@types/normalize-package-data@^2.4.0": version "2.4.4" @@ -8275,20 +8261,10 @@ unbox-primitive@^1.1.0: has-symbols "^1.1.0" which-boxed-primitive "^1.1.1" -undici-types@~5.26.4: - version "5.26.5" - 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.18.0: - version "7.18.2" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.18.2.tgz#29357a89e7b7ca4aef3bf0fd3fd0cd73884229e9" - integrity sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w== +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" From be7b0c89fd16e5abc078567a5f69d518eab7b10e Mon Sep 17 00:00:00 2001 From: Jitse De Smet Date: Fri, 27 Mar 2026 11:42:08 +0100 Subject: [PATCH 4/5] AI review --- packages/cli-utils/test/index.test.ts | 218 ++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 packages/cli-utils/test/index.test.ts diff --git a/packages/cli-utils/test/index.test.ts b/packages/cli-utils/test/index.test.ts new file mode 100644 index 00000000..86ce6db4 --- /dev/null +++ b/packages/cli-utils/test/index.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect } from 'vitest'; +import { + parseCliArgs, + getFlag, + getFlagAsBoolean, + getFlagAsString, + getFlagAsStrings, + parsePrefixMappings, +} from '../lib/index.js'; + +describe('parseCliArgs', () => { + it('parses positional arguments', () => { + const result = parseCliArgs([ 'file1.txt', 'file2.txt' ]); + expect(result.positionals).toEqual([ 'file1.txt', 'file2.txt' ]); + expect(result.flags).toEqual({}); + }); + + it('parses long flags without values as boolean true', () => { + const result = parseCliArgs([ '--verbose', '--debug' ]); + expect(result.flags).toEqual({ verbose: true, debug: true }); + }); + + it('parses long flags with space-separated values', () => { + const result = parseCliArgs([ '--output', 'file.txt' ]); + expect(result.flags).toEqual({ output: 'file.txt' }); + }); + + it('parses long flags with = separated values', () => { + const result = parseCliArgs([ '--output=file.txt' ]); + expect(result.flags).toEqual({ output: 'file.txt' }); + }); + + it('parses --no-* negation flags', () => { + const result = parseCliArgs([ '--no-color', '--no-cache' ]); + expect(result.flags).toEqual({ color: false, cache: false }); + }); + + it('parses short flags without values as boolean true', () => { + const result = parseCliArgs([ '-v', '-d' ]); + expect(result.flags).toEqual({ v: true, d: true }); + }); + + it('parses combined short flags', () => { + const result = parseCliArgs([ '-vd' ]); + expect(result.flags).toEqual({ v: true, d: true }); + }); + + it('parses short flags with values', () => { + const result = parseCliArgs([ '-o', 'file.txt' ]); + expect(result.flags).toEqual({ o: 'file.txt' }); + }); + + it('parses combined short flags with value on last flag', () => { + const result = parseCliArgs([ '-vo', 'file.txt' ]); + expect(result.flags).toEqual({ v: true, o: 'file.txt' }); + }); + + it('handles -- separator for positional-only mode', () => { + const result = parseCliArgs([ '--verbose', '--', '--not-a-flag' ]); + expect(result.flags).toEqual({ verbose: true }); + expect(result.positionals).toEqual([ '--not-a-flag' ]); + }); + + it('treats single dash as positional', () => { + const result = parseCliArgs([ '-' ]); + expect(result.positionals).toEqual([ '-' ]); + expect(result.flags).toEqual({}); + }); + + it('treats dash as value for flags', () => { + const result = parseCliArgs([ '--input', '-' ]); + expect(result.flags).toEqual({ input: '-' }); + }); + + it('collects repeated flags into array', () => { + const result = parseCliArgs([ '--prefix', 'a=b', '--prefix', 'c=d' ]); + expect(result.flags).toEqual({ prefix: [ 'a=b', 'c=d' ]}); + }); + + it('handles mixed positionals and flags', () => { + const result = parseCliArgs([ 'input.txt', '--verbose', '-o', 'output.txt', 'extra.txt' ]); + expect(result.positionals).toEqual([ 'input.txt', 'extra.txt' ]); + expect(result.flags).toEqual({ verbose: true, o: 'output.txt' }); + }); +}); + +describe('getFlag', () => { + it('returns flag value by name', () => { + const args = parseCliArgs([ '--verbose' ]); + expect(getFlag(args, 'verbose')).toBe(true); + }); + + it('returns undefined for missing flag', () => { + const args = parseCliArgs([]); + expect(getFlag(args, 'verbose')).toBeUndefined(); + }); + + it('checks multiple names and returns first match', () => { + const args = parseCliArgs([ '-h' ]); + expect(getFlag(args, 'help', 'h')).toBe(true); + }); +}); + +describe('getFlagAsBoolean', () => { + it('returns true for boolean true flag', () => { + const args = parseCliArgs([ '--verbose' ]); + expect(getFlagAsBoolean(args, 'verbose')).toBe(true); + }); + + it('returns false for missing flag', () => { + const args = parseCliArgs([]); + expect(getFlagAsBoolean(args, 'verbose')).toBe(false); + }); + + it('returns false for --no-* flag', () => { + const args = parseCliArgs([ '--no-verbose' ]); + expect(getFlagAsBoolean(args, 'verbose')).toBe(false); + }); + + it('returns false for string value "false"', () => { + const args = parseCliArgs([ '--verbose=false' ]); + expect(getFlagAsBoolean(args, 'verbose')).toBe(false); + }); + + it('returns false for string value "0"', () => { + const args = parseCliArgs([ '--verbose=0' ]); + expect(getFlagAsBoolean(args, 'verbose')).toBe(false); + }); + + it('returns true for other string values', () => { + const args = parseCliArgs([ '--verbose=yes' ]); + expect(getFlagAsBoolean(args, 'verbose')).toBe(true); + }); +}); + +describe('getFlagAsString', () => { + it('returns string value', () => { + const args = parseCliArgs([ '--output', 'file.txt' ]); + expect(getFlagAsString(args, 'output')).toBe('file.txt'); + }); + + it('returns undefined for missing flag', () => { + const args = parseCliArgs([]); + expect(getFlagAsString(args, 'output')).toBeUndefined(); + }); + + it('returns undefined for boolean flag', () => { + const args = parseCliArgs([ '--verbose' ]); + expect(getFlagAsString(args, 'verbose')).toBeUndefined(); + }); + + it('returns last value for repeated flag', () => { + const args = parseCliArgs([ '--output', 'first.txt', '--output', 'second.txt' ]); + expect(getFlagAsString(args, 'output')).toBe('second.txt'); + }); +}); + +describe('getFlagAsStrings', () => { + it('returns array of values for repeated flag', () => { + const args = parseCliArgs([ '--include', 'a', '--include', 'b' ]); + expect(getFlagAsStrings(args, 'include')).toEqual([ 'a', 'b' ]); + }); + + it('returns single-element array for single value', () => { + const args = parseCliArgs([ '--include', 'a' ]); + expect(getFlagAsStrings(args, 'include')).toEqual([ 'a' ]); + }); + + it('returns empty array for missing flag', () => { + const args = parseCliArgs([]); + expect(getFlagAsStrings(args, 'include')).toEqual([]); + }); + + it('returns empty array for boolean flag', () => { + const args = parseCliArgs([ '--verbose' ]); + expect(getFlagAsStrings(args, 'verbose')).toEqual([]); + }); +}); + +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' }); + }); +}); From f55645035b116ddc0ad1c41c990b33d2296fd09a Mon Sep 17 00:00:00 2001 From: Jitse De Smet Date: Fri, 27 Mar 2026 20:18:17 +0000 Subject: [PATCH 5/5] refactor(cli): replace custom arg parsing with commander, expand test coverage - Replace cli-utils monolithic index.ts with focused modules: io.ts (file I/O), service.ts (JSONL service), prefixes.ts (prefix parsing) - Remove parseCliArgs / getFlag* helpers from cli-utils public API; these are now handled by commander in each engine's bin script - Add injectable streams parameter to runJsonlService for testability - Rewrite all three bin scripts (parser, generator, algebra) to use commander instead of manual argument parsing; commander provides built-in --help generation and cleaner option handling - Fix bug in parser mergeContext: explicitly setting parseMode:undefined overwrites the parser's built-in default Set via spread, producing an empty parseMode Set and disabling variable/blank-node parsing for any request that carries a per-request context override - Add comprehensive tests: - packages/cli-utils: service mode (valid requests, invalid JSON, handler errors, ID propagation, blank lines, non-Error throws) - packages/cli-utils: I/O utilities (file read/write, JSON parse) - All three engine CLIs: service mode round-trips, error responses, batched requests, missing-field validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../bin/traqula-algebra-sparql-1-1.ts | 97 ++++---- engines/algebra-sparql-1-1/package.json | 3 +- engines/algebra-sparql-1-1/test/cli.test.ts | 172 +++++++++++++- .../bin/traqula-generator-sparql-1-1.ts | 81 ++++--- engines/generator-sparql-1-1/package.json | 3 +- engines/generator-sparql-1-1/test/cli.test.ts | 126 +++++++++- .../bin/traqula-parser-sparql-1-1.ts | 88 +++---- engines/parser-sparql-1-1/lib/cli.ts | 14 +- engines/parser-sparql-1-1/package.json | 3 +- engines/parser-sparql-1-1/test/cli.test.ts | 146 +++++++++++- packages/cli-utils/lib/index.ts | 219 +----------------- packages/cli-utils/lib/io.ts | 36 +++ packages/cli-utils/lib/prefixes.ts | 13 ++ packages/cli-utils/lib/service.ts | 68 ++++++ packages/cli-utils/test/index.test.ts | 178 +------------- packages/cli-utils/test/io.test.ts | 111 +++++++++ packages/cli-utils/test/service.test.ts | 160 +++++++++++++ yarn.lock | 19 +- 18 files changed, 1000 insertions(+), 537 deletions(-) create mode 100644 packages/cli-utils/lib/io.ts create mode 100644 packages/cli-utils/lib/prefixes.ts create mode 100644 packages/cli-utils/lib/service.ts create mode 100644 packages/cli-utils/test/io.test.ts create mode 100644 packages/cli-utils/test/service.test.ts 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 index 479360c9..7ff95abe 100644 --- 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 @@ -1,67 +1,70 @@ #!/usr/bin/env node import type { Algebra, ContextConfigs } from '@traqula/algebra-transformations-1-1'; import { - getFlagAsBoolean, - getFlagAsString, - getFlagAsStrings, - parseCliArgs, 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 printHelp(): void { - process.stdout.write(`Usage: traqula-algebra-sparql-1-1 [options]\n\n` + - `Translate between Traqula SPARQL AST JSON and SPARQL algebra JSON.\n\n` + - `Options:\n` + - ` -i, --input Read JSON input from file (defaults to stdin)\n` + - ` -o, --output Write JSON output to file (defaults to stdout)\n` + - ` --from Input format (default: ast)\n` + - ` --quads toAlgebra: convert patterns to quads\n` + - ` --blank-to-variable toAlgebra: rewrite blank nodes to variables\n` + - ` --base-iri toAlgebra: base IRI for relative resolution\n` + - ` --prefix toAlgebra: predefined prefixes (repeatable)\n` + - ` --pretty Pretty-print JSON output\n` + - ` --service [jsonl] Run JSONL service mode over stdio\n` + - ` -h, --help Show this help text\n\n` + - `Service request format:\n` + - ` {"id":"1","mode":"toAlgebra","input":{...},"options":{"quads":false}}\n`); +function collectStrings(val: string, prev: string[]): string[] { + return [ ...prev, val ]; } -function createToAlgebraOptions(args: ReturnType): ContextConfigs { - const prefixes = parsePrefixMappings(getFlagAsStrings(args, 'prefix')); - const options: ContextConfigs = { - quads: getFlagAsBoolean(args, 'quads'), - blankToVariable: getFlagAsBoolean(args, 'blank-to-variable'), - baseIRI: getFlagAsString(args, 'base-iri'), - prefixes, - }; - return options; +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 parseMode(args: ReturnType): 'toAlgebra' | 'toAst' { - const inputFormat = getFlagAsString(args, 'from'); - if (!inputFormat || inputFormat === 'ast') { +function parseFromOption(value: string): 'toAlgebra' | 'toAst' { + if (value === 'ast') { return 'toAlgebra'; } - if (inputFormat === 'algebra') { + if (value === 'algebra') { return 'toAst'; } - throw new Error(`Invalid value for --from: ${inputFormat}`); + throw new Error(`Invalid value for --from: ${value}. Expected 'ast' or 'algebra'`); } -async function run(): Promise { - const args = parseCliArgs(process.argv.slice(2)); - if (getFlagAsBoolean(args, 'help', 'h')) { - printHelp(); - return; - } +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 (getFlagAsBoolean(args, 'service')) { + + if (opts.service) { await runJsonlService((request: unknown) => { if (request === null || typeof request !== 'object') { throw new Error('Service request must be a JSON object'); @@ -88,23 +91,23 @@ async function run(): Promise { return; } - const mode = parseMode(args); - const input = await readJsonInput(getFlagAsString(args, 'input', 'i')); + const mode = parseFromOption(opts.from); + const input = await readJsonInput(opts.input); const output = mode === 'toAlgebra' ? handleAlgebraCliRequest(runtime, { mode: 'toAlgebra', input: input, - options: createToAlgebraOptions(args), + options: createToAlgebraOptions(opts), }) : handleAlgebraCliRequest(runtime, { mode: 'toAst', input: input, }); - await writeJsonOutput(output, getFlagAsBoolean(args, 'pretty'), getFlagAsString(args, 'output', 'o')); -} + await writeJsonOutput(output, opts.pretty ?? false, opts.output); +}); -run().catch((error: unknown) => { +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/package.json b/engines/algebra-sparql-1-1/package.json index adc5b969..41909065 100644 --- a/engines/algebra-sparql-1-1/package.json +++ b/engines/algebra-sparql-1-1/package.json @@ -51,7 +51,8 @@ "@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", diff --git a/engines/algebra-sparql-1-1/test/cli.test.ts b/engines/algebra-sparql-1-1/test/cli.test.ts index aff63f02..3ac6721f 100644 --- a/engines/algebra-sparql-1-1/test/cli.test.ts +++ b/engines/algebra-sparql-1-1/test/cli.test.ts @@ -1,11 +1,31 @@ +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 } from 'vitest'; +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', ({ expect }) => { - const parser = new Parser(); + it('converts AST to algebra and back', () => { const runtime = createAlgebraCliRuntime(); const ast = parser.parse('SELECT * WHERE { ?s ?p ?o }'); @@ -24,4 +44,150 @@ describe('algebra CLI runtime', () => { 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/generator-sparql-1-1/bin/traqula-generator-sparql-1-1.ts b/engines/generator-sparql-1-1/bin/traqula-generator-sparql-1-1.ts index cc40c193..1e6bb681 100644 --- 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 @@ -1,54 +1,58 @@ #!/usr/bin/env node import { - getFlagAsBoolean, - getFlagAsString, - parseCliArgs, 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'; -function printHelp(): void { - process.stdout.write(`Usage: traqula-generator-sparql-1-1 [options]\n\n` + - `Generate SPARQL 1.1 text from a Traqula AST JSON input.\n\n` + - `Options:\n` + - ` -i, --input Read AST JSON from file (defaults to stdin)\n` + - ` -o, --output Write generated SPARQL to file (defaults to stdout)\n` + - ` --path Treat input AST as a SPARQL path\n` + - ` --compact Disable pretty printing and newlines\n` + - ` --indent Configure indentation width\n` + - ` --newline-alt Separator used when compact mode disables newlines\n` + - ` --service [jsonl] Run JSONL service mode over stdio\n` + - ` -h, --help Show this help text\n\n` + - `Service request format:\n` + - ` {"id":"1","ast":{...},"path":false,"context":{...}}\n`); +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 parsePositiveInt(input: string, option: string): number { - const parsed = Number.parseInt(input, 10); +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}: ${input}`); + throw new Error(`Invalid value for --${option}: must be a non-negative integer`); } return parsed; } -function createDefaultContext(args: ReturnType): Partial { +function createDefaultContext(opts: Options): Partial { const context: Partial = {}; - const indent = getFlagAsString(args, 'indent'); - if (indent !== undefined) { - context.indentInc = parsePositiveInt(indent, 'indent'); + if (opts.indent !== undefined) { + context.indentInc = parseNonNegativeInt(opts.indent, 'indent'); } - const newlineAlt = getFlagAsString(args, 'newline-alt'); - if (newlineAlt !== undefined) { - context[traqulaNewlineAlternative] = newlineAlt; + if (opts.newlineAlt !== undefined) { + context[traqulaNewlineAlternative] = opts.newlineAlt; } - if (getFlagAsBoolean(args, 'compact')) { + if (opts.compact) { context[traqulaIndentation] = -1; context.indentInc = 0; } @@ -56,15 +60,10 @@ function createDefaultContext(args: ReturnType): Partial { - const args = parseCliArgs(process.argv.slice(2)); - if (getFlagAsBoolean(args, 'help', 'h')) { - printHelp(); - return; - } +program.action(async(opts: Options) => { + const runtime = createGeneratorCliRuntime(createDefaultContext(opts)); - const runtime = createGeneratorCliRuntime(createDefaultContext(args)); - if (getFlagAsBoolean(args, 'service')) { + if (opts.service) { await runJsonlService((request: unknown) => { if (request === null || typeof request !== 'object') { throw new Error('Service request must be a JSON object'); @@ -88,14 +87,14 @@ async function run(): Promise { return; } - const ast = await readJsonInput(getFlagAsString(args, 'input', 'i')); - const output = getFlagAsBoolean(args, 'path') ? + const ast = await readJsonInput(opts.input); + const output = opts.path ? handleGeneratorCliRequest(runtime, { ast: ast, path: true }) : handleGeneratorCliRequest(runtime, { ast: ast }); - await writeTextOutput(output, getFlagAsString(args, 'output', 'o')); -} + await writeTextOutput(output, opts.output); +}); -run().catch((error: unknown) => { +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/package.json b/engines/generator-sparql-1-1/package.json index 24e742d8..159c9b4e 100644 --- a/engines/generator-sparql-1-1/package.json +++ b/engines/generator-sparql-1-1/package.json @@ -49,7 +49,8 @@ "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", diff --git a/engines/generator-sparql-1-1/test/cli.test.ts b/engines/generator-sparql-1-1/test/cli.test.ts index 9486be64..03872851 100644 --- a/engines/generator-sparql-1-1/test/cli.test.ts +++ b/engines/generator-sparql-1-1/test/cli.test.ts @@ -1,10 +1,30 @@ +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 } from 'vitest'; +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', ({ expect }) => { - const parser = new Parser(); + it('generates a query from AST', () => { const runtime = createGeneratorCliRuntime(); const ast = parser.parse('SELECT * WHERE { ?s ?p ?o }'); @@ -12,4 +32,104 @@ describe('generator CLI runtime', () => { 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/parser-sparql-1-1/bin/traqula-parser-sparql-1-1.ts b/engines/parser-sparql-1-1/bin/traqula-parser-sparql-1-1.ts index 9b41d9e4..ccd16e13 100644 --- 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 @@ -1,50 +1,61 @@ #!/usr/bin/env node import { - getFlagAsBoolean, - getFlagAsString, - getFlagAsStrings, - parseCliArgs, 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 printHelp(): void { - process.stdout.write(`Usage: traqula-parser-sparql-1-1 [options]\n\n` + - `Parse SPARQL 1.1 input into a Traqula AST (JSON).\n\n` + - `Options:\n` + - ` -i, --input Read query text from file (defaults to stdin)\n` + - ` -o, --output Write JSON output to file (defaults to stdout)\n` + - ` --path Parse input as a SPARQL path expression\n` + - ` --base-iri Set default base IRI\n` + - ` --prefix Set default prefix mapping (repeatable)\n` + - ` --skip-validation Skip parser validation checks\n` + - ` --allow-vars Enable variable parsing mode\n` + - ` --allow-blank-nodes Enable blank node creation mode\n` + - ` --pretty Pretty-print JSON output\n` + - ` --service [jsonl] Run JSONL service mode over stdio\n` + - ` -h, --help Show this help text\n\n` + - `Service request format:\n` + - ` {"id":"1","query":"SELECT * WHERE { ?s ?p ?o }","path":false,"context":{...}}\n`); +function collectStrings(val: string, prev: string[]): string[] { + return [ ...prev, val ]; } -function createDefaultContext(args: ReturnType): Partial { +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 (getFlagAsBoolean(args, 'allow-vars')) { + if (opts.allowVars) { parseMode.add('canParseVars'); } - if (getFlagAsBoolean(args, 'allow-blank-nodes')) { + if (opts.allowBlankNodes) { parseMode.add('canCreateBlankNodes'); } - const prefixes = parsePrefixMappings(getFlagAsStrings(args, 'prefix')); const context: Partial = { - baseIRI: getFlagAsString(args, 'base-iri'), - skipValidation: getFlagAsBoolean(args, 'skip-validation'), - prefixes, + baseIRI: opts.baseIri, + skipValidation: opts.skipValidation, + prefixes: parsePrefixMappings(opts.prefix), }; if (parseMode.size > 0) { context.parseMode = parseMode; @@ -52,15 +63,10 @@ function createDefaultContext(args: ReturnType): Partial { - const args = parseCliArgs(process.argv.slice(2)); - if (getFlagAsBoolean(args, 'help', 'h')) { - printHelp(); - return; - } +program.action(async(opts: Options) => { + const runtime = createParserCliRuntime(createDefaultContext(opts)); - const runtime = createParserCliRuntime(createDefaultContext(args)); - if (getFlagAsBoolean(args, 'service')) { + if (opts.service) { await runJsonlService((request: unknown) => { if (request === null || typeof request !== 'object') { throw new Error('Service request must be a JSON object'); @@ -78,15 +84,15 @@ async function run(): Promise { return; } - const query = await readTextInput(getFlagAsString(args, 'input', 'i')); + const query = await readTextInput(opts.input); const output = handleParserCliRequest(runtime, { query, - path: getFlagAsBoolean(args, 'path'), + path: opts.path ?? false, }); - await writeJsonOutput(output, getFlagAsBoolean(args, 'pretty'), getFlagAsString(args, 'output', 'o')); -} + await writeJsonOutput(output, opts.pretty ?? false, opts.output); +}); -run().catch((error: unknown) => { +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 index 3a5ceca6..f317791e 100644 --- a/engines/parser-sparql-1-1/lib/cli.ts +++ b/engines/parser-sparql-1-1/lib/cli.ts @@ -33,10 +33,20 @@ function mergeContext( } const parseMode = override.parseMode ?? defaults.parseMode; - return { + const merged: Partial = { ...defaults, ...override, prefixes: { ...defaults.prefixes, ...override.prefixes }, - parseMode: parseMode ? new Set(parseMode) : undefined, }; + + // 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/package.json b/engines/parser-sparql-1-1/package.json index b5dc57f1..4fddf610 100644 --- a/engines/parser-sparql-1-1/package.json +++ b/engines/parser-sparql-1-1/package.json @@ -58,7 +58,8 @@ "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", diff --git a/engines/parser-sparql-1-1/test/cli.test.ts b/engines/parser-sparql-1-1/test/cli.test.ts index 546ffae1..d7b481a0 100644 --- a/engines/parser-sparql-1-1/test/cli.test.ts +++ b/engines/parser-sparql-1-1/test/cli.test.ts @@ -1,8 +1,27 @@ -import { describe, it } from 'vitest'; +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', ({ expect }) => { + it('parses a query', () => { const runtime = createParserCliRuntime(); const ast = <{ loc?: unknown; where?: unknown }> handleParserCliRequest(runtime, { query: 'SELECT * WHERE { ?s ?p ?o }', @@ -12,7 +31,7 @@ describe('parser CLI runtime', () => { expect(ast.where).toBeDefined(); }); - it('parses a path expression', ({ expect }) => { + it('parses a path expression', () => { const runtime = createParserCliRuntime(); const path = <{ loc?: unknown }> handleParserCliRequest(runtime, { query: ' / ', @@ -21,4 +40,125 @@ describe('parser CLI runtime', () => { 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/packages/cli-utils/lib/index.ts b/packages/cli-utils/lib/index.ts index b71f8ea3..419ba4e0 100644 --- a/packages/cli-utils/lib/index.ts +++ b/packages/cli-utils/lib/index.ts @@ -1,216 +1,3 @@ -/* eslint-disable import/no-nodejs-modules */ -import fs from 'node:fs/promises'; -import { createInterface } from 'node:readline'; - -export type CliArgValue = boolean | string | string[]; - -export interface ParsedCliArgs { - readonly positionals: string[]; - readonly flags: Readonly>; -} - -export function parseCliArgs(argv: readonly string[]): ParsedCliArgs { - const positionals: string[] = []; - const flags: Record = {}; - - let positionalOnly = false; - for (let index = 0; index < argv.length; index++) { - const value = argv[index]; - if (positionalOnly) { - positionals.push(value); - continue; - } - if (value === '--') { - positionalOnly = true; - continue; - } - if (!value.startsWith('-') || value === '-') { - positionals.push(value); - continue; - } - - if (value.startsWith('--')) { - const withNoPrefix = value.slice(2); - const equalIndex = withNoPrefix.indexOf('='); - const flag = equalIndex >= 0 ? withNoPrefix.slice(0, equalIndex) : withNoPrefix; - const inlineValue = equalIndex >= 0 ? withNoPrefix.slice(equalIndex + 1) : undefined; - if (flag.startsWith('no-')) { - flags[flag.slice(3)] = false; - continue; - } - if (inlineValue !== undefined) { - assignFlagValue(flags, flag, inlineValue); - continue; - } - const next = argv[index + 1]; - if (next !== undefined && (!next.startsWith('-') || next === '-')) { - assignFlagValue(flags, flag, next); - index++; - } else { - flags[flag] = true; - } - continue; - } - - const shortFlags = value.slice(1); - for (let shortIndex = 0; shortIndex < shortFlags.length; shortIndex++) { - const shortFlag = shortFlags[shortIndex]; - if (shortIndex < shortFlags.length - 1) { - flags[shortFlag] = true; - continue; - } - const next = argv[index + 1]; - if (next !== undefined && (!next.startsWith('-') || next === '-')) { - assignFlagValue(flags, shortFlag, next); - index++; - } else { - flags[shortFlag] = true; - } - } - } - - return { positionals, flags }; -} - -function assignFlagValue(flags: Record, key: string, value: string): void { - const current = flags[key]; - if (current === undefined || typeof current === 'boolean') { - flags[key] = value; - } else if (Array.isArray(current)) { - current.push(value); - } else { - flags[key] = [ current, value ]; - } -} - -export function getFlag(args: ParsedCliArgs, ...names: string[]): CliArgValue | undefined { - for (const name of names) { - const value = args.flags[name]; - if (value !== undefined) { - return value; - } - } - return undefined; -} - -export function getFlagAsString(args: ParsedCliArgs, ...names: string[]): string | undefined { - const flag = getFlag(args, ...names); - if (flag === undefined || typeof flag === 'boolean') { - return undefined; - } - return Array.isArray(flag) ? flag.at(-1) : flag; -} - -export function getFlagAsBoolean(args: ParsedCliArgs, ...names: string[]): boolean { - const flag = getFlag(args, ...names); - if (flag === undefined) { - return false; - } - if (typeof flag === 'boolean') { - return flag; - } - const value = Array.isArray(flag) ? flag.at(-1) : flag; - return value !== 'false' && value !== '0'; -} - -export function getFlagAsStrings(args: ParsedCliArgs, ...names: string[]): string[] { - const flag = getFlag(args, ...names); - if (flag === undefined || typeof flag === 'boolean') { - return []; - } - return Array.isArray(flag) ? flag : [ flag ]; -} - -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); -} - -export interface JsonlResponse { - readonly id?: number | string; - readonly ok: boolean; - readonly result?: unknown; - readonly error?: { readonly message: string }; -} - -export async function runJsonlService( - handler: (request: unknown) => Promise | unknown, -): Promise { - const rl = createInterface({ input: process.stdin, 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 { - process.stdout.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, - }; - process.stdout.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 }, - }; - process.stdout.write(`${JSON.stringify(response)}\n`); - } - } -} - -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; -} +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/test/index.test.ts b/packages/cli-utils/test/index.test.ts index 86ce6db4..0d05cdf1 100644 --- a/packages/cli-utils/test/index.test.ts +++ b/packages/cli-utils/test/index.test.ts @@ -1,181 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { - parseCliArgs, - getFlag, - getFlagAsBoolean, - getFlagAsString, - getFlagAsStrings, - parsePrefixMappings, -} from '../lib/index.js'; - -describe('parseCliArgs', () => { - it('parses positional arguments', () => { - const result = parseCliArgs([ 'file1.txt', 'file2.txt' ]); - expect(result.positionals).toEqual([ 'file1.txt', 'file2.txt' ]); - expect(result.flags).toEqual({}); - }); - - it('parses long flags without values as boolean true', () => { - const result = parseCliArgs([ '--verbose', '--debug' ]); - expect(result.flags).toEqual({ verbose: true, debug: true }); - }); - - it('parses long flags with space-separated values', () => { - const result = parseCliArgs([ '--output', 'file.txt' ]); - expect(result.flags).toEqual({ output: 'file.txt' }); - }); - - it('parses long flags with = separated values', () => { - const result = parseCliArgs([ '--output=file.txt' ]); - expect(result.flags).toEqual({ output: 'file.txt' }); - }); - - it('parses --no-* negation flags', () => { - const result = parseCliArgs([ '--no-color', '--no-cache' ]); - expect(result.flags).toEqual({ color: false, cache: false }); - }); - - it('parses short flags without values as boolean true', () => { - const result = parseCliArgs([ '-v', '-d' ]); - expect(result.flags).toEqual({ v: true, d: true }); - }); - - it('parses combined short flags', () => { - const result = parseCliArgs([ '-vd' ]); - expect(result.flags).toEqual({ v: true, d: true }); - }); - - it('parses short flags with values', () => { - const result = parseCliArgs([ '-o', 'file.txt' ]); - expect(result.flags).toEqual({ o: 'file.txt' }); - }); - - it('parses combined short flags with value on last flag', () => { - const result = parseCliArgs([ '-vo', 'file.txt' ]); - expect(result.flags).toEqual({ v: true, o: 'file.txt' }); - }); - - it('handles -- separator for positional-only mode', () => { - const result = parseCliArgs([ '--verbose', '--', '--not-a-flag' ]); - expect(result.flags).toEqual({ verbose: true }); - expect(result.positionals).toEqual([ '--not-a-flag' ]); - }); - - it('treats single dash as positional', () => { - const result = parseCliArgs([ '-' ]); - expect(result.positionals).toEqual([ '-' ]); - expect(result.flags).toEqual({}); - }); - - it('treats dash as value for flags', () => { - const result = parseCliArgs([ '--input', '-' ]); - expect(result.flags).toEqual({ input: '-' }); - }); - - it('collects repeated flags into array', () => { - const result = parseCliArgs([ '--prefix', 'a=b', '--prefix', 'c=d' ]); - expect(result.flags).toEqual({ prefix: [ 'a=b', 'c=d' ]}); - }); - - it('handles mixed positionals and flags', () => { - const result = parseCliArgs([ 'input.txt', '--verbose', '-o', 'output.txt', 'extra.txt' ]); - expect(result.positionals).toEqual([ 'input.txt', 'extra.txt' ]); - expect(result.flags).toEqual({ verbose: true, o: 'output.txt' }); - }); -}); - -describe('getFlag', () => { - it('returns flag value by name', () => { - const args = parseCliArgs([ '--verbose' ]); - expect(getFlag(args, 'verbose')).toBe(true); - }); - - it('returns undefined for missing flag', () => { - const args = parseCliArgs([]); - expect(getFlag(args, 'verbose')).toBeUndefined(); - }); - - it('checks multiple names and returns first match', () => { - const args = parseCliArgs([ '-h' ]); - expect(getFlag(args, 'help', 'h')).toBe(true); - }); -}); - -describe('getFlagAsBoolean', () => { - it('returns true for boolean true flag', () => { - const args = parseCliArgs([ '--verbose' ]); - expect(getFlagAsBoolean(args, 'verbose')).toBe(true); - }); - - it('returns false for missing flag', () => { - const args = parseCliArgs([]); - expect(getFlagAsBoolean(args, 'verbose')).toBe(false); - }); - - it('returns false for --no-* flag', () => { - const args = parseCliArgs([ '--no-verbose' ]); - expect(getFlagAsBoolean(args, 'verbose')).toBe(false); - }); - - it('returns false for string value "false"', () => { - const args = parseCliArgs([ '--verbose=false' ]); - expect(getFlagAsBoolean(args, 'verbose')).toBe(false); - }); - - it('returns false for string value "0"', () => { - const args = parseCliArgs([ '--verbose=0' ]); - expect(getFlagAsBoolean(args, 'verbose')).toBe(false); - }); - - it('returns true for other string values', () => { - const args = parseCliArgs([ '--verbose=yes' ]); - expect(getFlagAsBoolean(args, 'verbose')).toBe(true); - }); -}); - -describe('getFlagAsString', () => { - it('returns string value', () => { - const args = parseCliArgs([ '--output', 'file.txt' ]); - expect(getFlagAsString(args, 'output')).toBe('file.txt'); - }); - - it('returns undefined for missing flag', () => { - const args = parseCliArgs([]); - expect(getFlagAsString(args, 'output')).toBeUndefined(); - }); - - it('returns undefined for boolean flag', () => { - const args = parseCliArgs([ '--verbose' ]); - expect(getFlagAsString(args, 'verbose')).toBeUndefined(); - }); - - it('returns last value for repeated flag', () => { - const args = parseCliArgs([ '--output', 'first.txt', '--output', 'second.txt' ]); - expect(getFlagAsString(args, 'output')).toBe('second.txt'); - }); -}); - -describe('getFlagAsStrings', () => { - it('returns array of values for repeated flag', () => { - const args = parseCliArgs([ '--include', 'a', '--include', 'b' ]); - expect(getFlagAsStrings(args, 'include')).toEqual([ 'a', 'b' ]); - }); - - it('returns single-element array for single value', () => { - const args = parseCliArgs([ '--include', 'a' ]); - expect(getFlagAsStrings(args, 'include')).toEqual([ 'a' ]); - }); - - it('returns empty array for missing flag', () => { - const args = parseCliArgs([]); - expect(getFlagAsStrings(args, 'include')).toEqual([]); - }); - - it('returns empty array for boolean flag', () => { - const args = parseCliArgs([ '--verbose' ]); - expect(getFlagAsStrings(args, 'verbose')).toEqual([]); - }); -}); +import { parsePrefixMappings } from '../lib/index.js'; describe('parsePrefixMappings', () => { it('parses valid prefix=iri mappings', () => { 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/yarn.lock b/yarn.lock index 8f001a39..e0e1878f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1678,13 +1678,20 @@ "@rdfjs/types" "*" "@types/node" "*" -"@types/node@*", "@types/node@^18.0.0", "@types/node@^24.3.1": +"@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.16.0" +"@types/node@^18.0.0": + 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" + "@types/normalize-package-data@^2.4.0": version "2.4.4" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901" @@ -2816,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" @@ -8261,6 +8273,11 @@ unbox-primitive@^1.1.0: has-symbols "^1.1.0" which-boxed-primitive "^1.1.1" +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + undici-types@~7.16.0: version "7.16.0" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46"