Skip to content

Commit 7de9252

Browse files
committed
Support output formats
Adds support for printing CSV, JSON, Markdown, and LaTeX. Includes support for new --output-format CLI argument as well as new system functions )output.* for toggling output formats interactively at the REPL.
1 parent b9109a9 commit 7de9252

File tree

7 files changed

+185
-42
lines changed

7 files changed

+185
-42
lines changed

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,19 +50,19 @@ Non-exhaustive list:
5050
- TODO: Test coverage.
5151
- TODO: Correct usage of `goal.NewError` vs. `goal.NewPanicError`
5252
- TODO: Option for raw REPL (modeled on Goal's) with better input performance, no auto-complete etc.
53-
- TODO: Goal function to return auto-complete results (esp. if raw REPL is being used).
54-
- TODO: Looser auto-complete, not just prefix-based
5553
- TODO: Functions to conveniently populate SQL tables with Goal values.
5654
- TODO: Support plots/charts (consider https://github.com/wcharczuk/go-chart)
5755
- TODO: User commands (as found in [APL](https://aplwiki.com/wiki/User_command)), executable from Goal or SQL modes
5856

5957
I plan to support the above items. The following are stretch goals or nice-to-have's:
6058

59+
- TODO: Use custom table functions via replacement scan to query Goal tables from DuckDB.
60+
- TODO: Looser auto-complete, not just prefix-based
61+
- TODO: `)help`
6162
- TODO: Functions leveraging [time.Time](https://pkg.go.dev/time@go1.22.5)
62-
- TODO: `tui.` functions in CLI mode using https://github.com/charmbracelet/lipgloss (already a transitive dependency) for colored output, etc.
63+
- IN PROGRESS: `tui.` functions in CLI mode using https://github.com/charmbracelet/lipgloss (already a transitive dependency) for colored output, etc.
6364
- TODO: Implement a subset of [q](https://code.kx.com/q/) functions to extend what Goal already has.
6465
- Specific user commands:
65-
- TODO: Choosing output format (e.g., as JSON, perhaps all the ones DuckDB supports)
6666
- TODO: Toggle pretty-printing
6767
- TODO: Toggle paging at the REPL (as found in [PicoLisp](https://picolisp.com/wiki/?home))
6868
- TODO: Toggle colored output

cmd/ari/input.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,18 @@ func (autoCompleter *AutoCompleter) cacheSystemCommands() {
397397

398398
func systemCommands() (map[string]string, []string) {
399399
m := map[string]string{
400-
")goal": "Goal array language mode", ")sql": "Read-only SQL mode (querying)", ")sql!": "Read/write SQL mode",
400+
")goal": "Goal array language mode",
401+
// TODO Output formats: https://duckdb.org/docs/api/cli/output_formats.html
402+
// In particular csv, json, markdown, latex, and one of the boxed ones
403+
")output.csv": "Print results as CSV",
404+
")output.goal": "Print results as Goal values (default)",
405+
")output.json": "Print results as JSON",
406+
")output.json+pretty": "Print results as JSON with indentation",
407+
")output.latex": "Print results as LaTeX",
408+
")output.markdown": "Print results as Markdown",
409+
")output.tsv": "Print results as TSV",
410+
")sql": "Read-only SQL mode (querying)",
411+
")sql!": "Read/write SQL mode",
401412
}
402413
// Prepare sorted keys ahead of time
403414
keys := make([]string, 0, len(m))

cmd/ari/root.go

Lines changed: 130 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@ const (
3333
cliModeSQLReadWrite
3434
)
3535

36+
type outputFormat int
37+
38+
const (
39+
outputFormatGoal outputFormat = iota
40+
outputFormatCSV
41+
outputFormatJSON
42+
outputFormatJSONPretty
43+
outputFormatLatex
44+
outputFormatMarkdown
45+
outputFormatTSV
46+
)
47+
3648
const (
3749
cliModeGoalPrompt = " "
3850
cliModeGoalNextPrompt = " "
@@ -43,11 +55,12 @@ const (
4355
)
4456

4557
type CliSystem struct {
58+
ariContext *ari.Context
59+
autoCompleter *AutoCompleter
4660
cliEditor *bubbline.Editor
4761
cliMode cliMode
48-
autoCompleter *AutoCompleter
49-
ariContext *ari.Context
5062
debug bool
63+
outputFormat outputFormat
5164
programName string
5265
}
5366

@@ -138,7 +151,28 @@ func cliModeFromString(s string) (cliMode, error) {
138151
case "sql!":
139152
return cliModeSQLReadWrite, nil
140153
default:
141-
return 0, errors.New("unsupported ari mode: " + s)
154+
return 0, errors.New("unsupported --mode: " + s)
155+
}
156+
}
157+
158+
func outputFormatFromString(s string) (outputFormat, error) {
159+
switch s {
160+
case "csv":
161+
return outputFormatCSV, nil
162+
case "goal":
163+
return outputFormatGoal, nil
164+
case "json":
165+
return outputFormatJSON, nil
166+
case "json+pretty":
167+
return outputFormatJSONPretty, nil
168+
case "latex":
169+
return outputFormatLatex, nil
170+
case "markdown":
171+
return outputFormatMarkdown, nil
172+
case "tsv":
173+
return outputFormatTSV, nil
174+
default:
175+
return 0, errors.New("unsupported --output-format: " + s)
142176
}
143177
}
144178

@@ -191,16 +225,27 @@ func ariMain(cmd *cobra.Command, args []string) int {
191225
defer pprof.StopCPUProfile()
192226
}
193227

228+
// Defaults to outputFormatGoal
229+
startupOutputFormatString := viper.GetString("output-format")
230+
startupOutputFormat, err := outputFormatFromString(startupOutputFormatString)
231+
if err != nil {
232+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
233+
return 1
234+
}
235+
mainCliSystem.outputFormat = startupOutputFormat
236+
194237
// MUST PRECEDE EXECUTE/REPL
195238
goalFilesToLoad := viper.GetStringSlice("load")
196239
for _, f := range goalFilesToLoad {
197-
err = runScript(&mainCliSystem, f)
240+
_, err = runScript(&mainCliSystem, f)
198241
if err != nil {
199242
fmt.Fprintf(os.Stderr, "Failed to load file %q with error: %v", f, err)
200243
return 1
201244
}
202245
}
203246

247+
// By default, we don't print the final return value of a script, but this flag supports that.
248+
printFinalValue := viper.GetBool("println")
204249
// Support file argument both with -e and standalone.
205250
hasFileArgument := len(args) > 0
206251

@@ -211,13 +256,16 @@ func ariMain(cmd *cobra.Command, args []string) int {
211256
return 1
212257
}
213258
if programToExecute != "" {
214-
err = runCommand(&mainCliSystem, programToExecute)
215-
if err != nil {
259+
goalV, errr := runCommand(&mainCliSystem, programToExecute)
260+
if errr != nil {
216261
fmt.Fprintf(os.Stderr, "Failed to execute program:\n%q\n with error:\n%v\n", programToExecute, err)
217262
return 1
218263
}
219264
// Support -e/--execute along with a file argument.
220265
if !hasFileArgument {
266+
if printFinalValue {
267+
printInOutputFormat(ariContext.GoalContext, mainCliSystem.outputFormat, goalV)
268+
}
221269
return 0
222270
}
223271
}
@@ -230,11 +278,14 @@ func ariMain(cmd *cobra.Command, args []string) int {
230278
fmt.Fprintf(os.Stderr, "File %q is not recognized as a path on your system: %v", f, err)
231279
}
232280
ariContext.GoalContext.AssignGlobal("FILE", goal.NewS(path))
233-
err = runScript(&mainCliSystem, f)
234-
if err != nil {
281+
goalV, errr := runScript(&mainCliSystem, f)
282+
if errr != nil {
235283
fmt.Fprintf(os.Stderr, "Failed to run file %q with error: %v", f, err)
236284
return 1
237285
}
286+
if printFinalValue {
287+
printInOutputFormat(ariContext.GoalContext, mainCliSystem.outputFormat, goalV)
288+
}
238289
return 0
239290
}
240291

@@ -332,18 +383,54 @@ func (cliSystem *CliSystem) replEvalGoal(line string) {
332383
}
333384

334385
if !goalContext.AssignedLast() {
335-
ariPrintFn := cliSystem.detectAriPrint()
386+
// In the REPL, make it easy to get the value of the _p_revious expression
387+
// just evaluated. Equivalent of *1 in Lisp REPLs. Skip assignments.
388+
printInOutputFormat(goalContext, cliSystem.outputFormat, value)
389+
}
390+
391+
cliSystem.detectAriPrompt()
392+
}
393+
394+
func printInOutputFormat(goalContext *goal.Context, outputFormat outputFormat, value goal.V) {
395+
goalContext.AssignGlobal("ari.p", value)
396+
switch outputFormat {
397+
case outputFormatGoal:
398+
ariPrintFn := detectAriPrint(goalContext)
336399
if ariPrintFn != nil {
337400
ariPrintFn(value)
338401
} else {
339402
fmt.Fprintln(os.Stdout, value.Sprint(goalContext, false))
340403
}
341-
// In the REPL, make it easy to get the value of the _p_revious expression
342-
// just evaluated. Equivalent of *1 in Lisp REPLs. Skip assignments.
343-
goalContext.AssignGlobal("ari.p", value)
404+
case outputFormatCSV:
405+
evalThen(goalContext, value, `csv ari.p`)
406+
case outputFormatJSON:
407+
evalThen(goalContext, value, `""json ari.p`)
408+
case outputFormatJSONPretty:
409+
evalThen(goalContext, value, `" "json ari.p`)
410+
case outputFormatLatex:
411+
evalThen(goalContext, value, `out.ltx[ari.p;"%.2f"]`)
412+
case outputFormatMarkdown:
413+
evalThen(goalContext, value, `out.md[ari.p;"%.2f"]`)
414+
case outputFormatTSV:
415+
evalThen(goalContext, value, `"\t"csv ari.p`)
344416
}
417+
}
345418

346-
cliSystem.detectAriPrompt()
419+
// evalThen evaluates the given goalProgram for side effects, with ari.p already bound to previous evaluation.
420+
func evalThen(goalContext *goal.Context, value goal.V, goalProgram string) {
421+
nextValue, err := goalContext.Eval(goalProgram)
422+
if err != nil {
423+
formatREPLError(err)
424+
}
425+
if value.IsError() {
426+
formatREPLError(newExitError(goalContext, value.Error()))
427+
}
428+
switch jsonS := nextValue.BV().(type) {
429+
case goal.S:
430+
fmt.Fprintln(os.Stdout, string(jsonS))
431+
default:
432+
formatREPLError(errors.New("developer error: json must produce a string"))
433+
}
347434
}
348435

349436
// ExitError is returned by Cmd when the program returns a Goal error value.
@@ -411,8 +498,7 @@ func (cliSystem *CliSystem) detectAriPrompt() {
411498
}
412499

413500
// detectAriPrint returns a function for printing values at the REPL in goal mode.
414-
func (cliSystem *CliSystem) detectAriPrint() func(goal.V) {
415-
goalContext := cliSystem.ariContext.GoalContext
501+
func detectAriPrint(goalContext *goal.Context) func(goal.V) {
416502
printFn, found := goalContext.GetGlobal("ari.print")
417503
if found {
418504
if printFn.IsCallable() {
@@ -459,9 +545,22 @@ func (cliSystem *CliSystem) replEvalSystemCommand(line string) error {
459545
cmdAndArgs := strings.Split(line, " ")
460546
systemCommand := cmdAndArgs[0]
461547
switch systemCommand {
462-
// IDEA )help that doesn't require quoting
463548
case ")goal":
464549
return cliSystem.switchMode(cliModeGoal, nil)
550+
case ")output.goal":
551+
cliSystem.outputFormat = outputFormatGoal
552+
case ")output.csv":
553+
cliSystem.outputFormat = outputFormatCSV
554+
case ")output.json":
555+
cliSystem.outputFormat = outputFormatJSON
556+
case ")output.json+pretty":
557+
cliSystem.outputFormat = outputFormatJSONPretty
558+
case ")output.latex":
559+
cliSystem.outputFormat = outputFormatLatex
560+
case ")output.markdown":
561+
cliSystem.outputFormat = outputFormatMarkdown
562+
case ")output.tsv":
563+
cliSystem.outputFormat = outputFormatTSV
465564
case ")sql":
466565
return cliSystem.switchMode(cliModeSQLReadOnly, cmdAndArgs[1:])
467566
case ")sql!":
@@ -510,43 +609,43 @@ func debugPrintStack(ctx *goal.Context, programName string) {
510609
}
511610

512611
// Adapted from Goal's implementation.
513-
func runCommand(cliSystem *CliSystem, cmd string) error {
612+
func runCommand(cliSystem *CliSystem, cmd string) (goal.V, error) {
514613
return runSource(cliSystem, cmd, "")
515614
}
516615

517616
// Adapted from Goal's implementation.
518-
func runScript(cliSystem *CliSystem, fname string) error {
617+
func runScript(cliSystem *CliSystem, fname string) (goal.V, error) {
519618
bs, err := os.ReadFile(fname)
520619
if err != nil {
521-
return fmt.Errorf("%s: %w", cliSystem.programName, err)
620+
return goal.NewGap(), fmt.Errorf("%s: %w", cliSystem.programName, err)
522621
}
523622
// We avoid redundant copy in bytes->string conversion.
524623
source := unsafe.String(unsafe.SliceData(bs), len(bs))
525624
return runSource(cliSystem, source, fname)
526625
}
527626

528627
// Adapted from Goal's implementation.
529-
func runSource(cliSystem *CliSystem, source, loc string) error {
628+
func runSource(cliSystem *CliSystem, source, loc string) (goal.V, error) {
530629
goalContext := cliSystem.ariContext.GoalContext
531630
err := goalContext.Compile(source, loc, "")
532631
if err != nil {
533632
if cliSystem.debug {
534633
printProgram(goalContext, cliSystem.programName)
535634
}
536-
return formatError(cliSystem.programName, err)
635+
return goal.NewGap(), formatError(cliSystem.programName, err)
537636
}
538637
if cliSystem.debug {
539638
printProgram(goalContext, cliSystem.programName)
540-
return nil
639+
return goal.NewGap(), nil
541640
}
542641
r, err := goalContext.Run()
543642
if err != nil {
544-
return formatError(cliSystem.programName, err)
643+
return r, formatError(cliSystem.programName, err)
545644
}
546645
if r.IsError() {
547-
return fmt.Errorf("%s", formatGoalError(goalContext, r))
646+
return r, fmt.Errorf("%s", formatGoalError(goalContext, r))
548647
}
549-
return nil
648+
return r, nil
550649
}
551650

552651
// printProgram prints debug information about the context and any compiled
@@ -655,21 +754,15 @@ working with SQL and HTTP APIs.`,
655754
var cfgFile string
656755
cobra.OnInitialize(initConfigFn(cfgFile))
657756

658-
// Here you will define your flags and configuration settings.
659-
// Cobra supports persistent flags, which, if defined here,
660-
// will be global for your application.
661-
662757
home, err := os.UserHomeDir()
663758
cobra.CheckErr(err)
664759
cfgDir := path.Join(home, ".config", "ari")
665-
666760
defaultHistFile := path.Join(cfgDir, "ari-history.txt")
667761
defaultCfgFile := path.Join(cfgDir, "ari-config.yaml")
668762

669763
// Config file has processing in initConfigFn outside of viper lifecycle, so it's a separate variable.
670764
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", defaultCfgFile, "ari configuration")
671765

672-
// Everything else should go through viper for consistency.
673766
pFlags := rootCmd.PersistentFlags()
674767

675768
flagNameHistory := "history"
@@ -692,6 +785,12 @@ working with SQL and HTTP APIs.`,
692785
rootCmd.Flags().StringP("mode", "m", "goal", "language mode at startup")
693786
err = viper.BindPFlag("mode", rootCmd.Flags().Lookup("mode"))
694787
cobra.CheckErr(err)
788+
rootCmd.Flags().StringP("output-format", "f", "goal", "evaluation output format")
789+
err = viper.BindPFlag("output-format", rootCmd.Flags().Lookup("output-format"))
790+
cobra.CheckErr(err)
791+
rootCmd.Flags().BoolP("println", "p", false, "print final value of the script + newline")
792+
err = viper.BindPFlag("println", rootCmd.Flags().Lookup("println"))
793+
cobra.CheckErr(err)
695794
rootCmd.Flags().BoolP("version", "v", false, "print version info and exit")
696795

697796
// NB: MUST be last in this method.

goal.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,24 @@ const (
2323
// goalLoadExtendedPreamble loads the goalSource* snippets below,
2424
// loading them into the Goal context.
2525
func goalLoadExtendedPreamble(ctx *goal.Context) error {
26-
goalPackages := map[string]string{
27-
"": goalSourceShape + goalSourceTable,
26+
corePackages := map[string]string{
27+
"": goalSourceShape + "\n" + goalSourceTable,
28+
}
29+
additionalPackages := map[string]string{
2830
"fmt": goalSourceFmt,
2931
"html": goalSourceHTML,
3032
"k": goalSourceK,
33+
"out": goalSourceOut,
3134
"math": goalSourceMath,
3235
"mods": goalSourceMods,
3336
}
34-
for pkg, source := range goalPackages {
37+
for pkg, source := range corePackages {
38+
_, err := ctx.EvalPackage(source, "<builtin>", pkg)
39+
if err != nil {
40+
return err
41+
}
42+
}
43+
for pkg, source := range additionalPackages {
3544
_, err := ctx.EvalPackage(source, "<builtin>", pkg)
3645
if err != nil {
3746
return err
@@ -58,6 +67,9 @@ var goalSourceMods string
5867
//go:embed vendor-goal/shape.goal
5968
var goalSourceShape string
6069

70+
//go:embed vendor-goal/out.goal
71+
var goalSourceOut string
72+
6173
//go:embed vendor-goal/table.goal
6274
var goalSourceTable string
6375

testing/table.goal

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
11
t:csv.t ","csv 'read"data/starwars.csv";(#*t;!t) / (87;"name""height""mass""hair_color""skin_color""eye_color""birth_year""sex""gender""homeworld""species""films""vehicles""starships")
22
t:json.t@json rq/[{"a":1,"b":2},{"a":10,"b":20},{"a":100,"b":200}]/;(#*t;!t) / (3;"a""b")
3-

0 commit comments

Comments
 (0)