From 0131d6d95584242fbe871aca257620328d37640c Mon Sep 17 00:00:00 2001 From: Arpad Ryszka Date: Mon, 18 Aug 2025 14:24:31 +0200 Subject: [PATCH] init repo --- .cover | 457 ++++++++++++++++++++++++++++++++++++++++++ Makefile | 21 ++ apply.go | 234 +++++++++++++++++++++ cmd/wand-docs/main.go | 12 ++ command.go | 236 ++++++++++++++++++++++ commandline.go | 363 +++++++++++++++++++++++++++++++++ commandline_test.go | 239 ++++++++++++++++++++++ env.go | 70 +++++++ env_test.go | 42 ++++ exec.go | 60 ++++++ exec_test.go | 45 +++++ go.mod | 8 + go.sum | 4 + help.go | 81 ++++++++ input.go | 166 +++++++++++++++ notes.txt | 4 + output.go | 25 +++ reflect.go | 298 +++++++++++++++++++++++++++ wand.go | 42 ++++ wand_test.go | 1 + 20 files changed, 2408 insertions(+) create mode 100644 .cover create mode 100644 Makefile create mode 100644 apply.go create mode 100644 cmd/wand-docs/main.go create mode 100644 command.go create mode 100644 commandline.go create mode 100644 commandline_test.go create mode 100644 env.go create mode 100644 env_test.go create mode 100644 exec.go create mode 100644 exec_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 help.go create mode 100644 input.go create mode 100644 notes.txt create mode 100644 output.go create mode 100644 reflect.go create mode 100644 wand.go create mode 100644 wand_test.go diff --git a/.cover b/.cover new file mode 100644 index 0000000..e21ca3a --- /dev/null +++ b/.cover @@ -0,0 +1,457 @@ +mode: set +code.squareroundforest.org/arpio/wand/cmd/wand-docs/main.go:3.14,4.2 0 0 +code.squareroundforest.org/arpio/wand/apply.go:9.54,10.15 1 0 +code.squareroundforest.org/arpio/wand/apply.go:10.15,12.3 1 0 +code.squareroundforest.org/arpio/wand/apply.go:14.2,14.31 1 0 +code.squareroundforest.org/arpio/wand/apply.go:17.52,18.17 1 1 +code.squareroundforest.org/arpio/wand/apply.go:18.17,22.3 3 1 +code.squareroundforest.org/arpio/wand/apply.go:24.2,24.17 1 1 +code.squareroundforest.org/arpio/wand/apply.go:24.17,27.3 2 0 +code.squareroundforest.org/arpio/wand/apply.go:29.2,29.31 1 1 +code.squareroundforest.org/arpio/wand/apply.go:29.31,31.3 1 1 +code.squareroundforest.org/arpio/wand/apply.go:34.47,35.25 1 1 +code.squareroundforest.org/arpio/wand/apply.go:36.23,37.32 1 0 +code.squareroundforest.org/arpio/wand/apply.go:38.21,39.30 1 1 +code.squareroundforest.org/arpio/wand/apply.go:43.50,45.2 1 0 +code.squareroundforest.org/arpio/wand/apply.go:47.48,48.31 1 1 +code.squareroundforest.org/arpio/wand/apply.go:48.31,50.3 1 1 +code.squareroundforest.org/arpio/wand/apply.go:53.41,54.14 1 1 +code.squareroundforest.org/arpio/wand/apply.go:54.14,57.3 2 1 +code.squareroundforest.org/arpio/wand/apply.go:59.2,59.47 1 1 +code.squareroundforest.org/arpio/wand/apply.go:62.52,63.22 1 1 +code.squareroundforest.org/arpio/wand/apply.go:64.23,65.28 1 0 +code.squareroundforest.org/arpio/wand/apply.go:66.21,67.26 1 1 +code.squareroundforest.org/arpio/wand/apply.go:68.10,69.24 1 1 +code.squareroundforest.org/arpio/wand/apply.go:73.56,74.43 1 1 +code.squareroundforest.org/arpio/wand/apply.go:74.43,80.10 6 1 +code.squareroundforest.org/arpio/wand/apply.go:81.39,83.24 2 1 +code.squareroundforest.org/arpio/wand/apply.go:84.55,86.39 2 0 +code.squareroundforest.org/arpio/wand/apply.go:86.39,89.5 2 0 +code.squareroundforest.org/arpio/wand/apply.go:90.21,92.33 2 0 +code.squareroundforest.org/arpio/wand/apply.go:97.100,101.23 4 1 +code.squareroundforest.org/arpio/wand/apply.go:101.23,103.3 1 1 +code.squareroundforest.org/arpio/wand/apply.go:105.2,106.42 2 1 +code.squareroundforest.org/arpio/wand/apply.go:106.42,109.3 2 1 +code.squareroundforest.org/arpio/wand/apply.go:111.2,112.23 2 1 +code.squareroundforest.org/arpio/wand/apply.go:112.23,114.41 2 1 +code.squareroundforest.org/arpio/wand/apply.go:114.41,116.4 1 1 +code.squareroundforest.org/arpio/wand/apply.go:118.3,118.28 1 1 +code.squareroundforest.org/arpio/wand/apply.go:121.2,122.26 2 1 +code.squareroundforest.org/arpio/wand/apply.go:122.26,123.12 1 1 +code.squareroundforest.org/arpio/wand/apply.go:123.12,125.4 1 1 +code.squareroundforest.org/arpio/wand/apply.go:128.2,129.20 2 1 +code.squareroundforest.org/arpio/wand/apply.go:129.20,130.12 1 1 +code.squareroundforest.org/arpio/wand/apply.go:130.12,132.4 1 1 +code.squareroundforest.org/arpio/wand/apply.go:135.2,135.50 1 1 +code.squareroundforest.org/arpio/wand/apply.go:135.50,137.3 1 1 +code.squareroundforest.org/arpio/wand/apply.go:139.2,140.29 2 1 +code.squareroundforest.org/arpio/wand/apply.go:140.29,142.34 2 1 +code.squareroundforest.org/arpio/wand/apply.go:142.34,144.4 1 1 +code.squareroundforest.org/arpio/wand/apply.go:146.3,146.27 1 1 +code.squareroundforest.org/arpio/wand/apply.go:149.2,149.33 1 1 +code.squareroundforest.org/arpio/wand/apply.go:149.33,151.28 2 1 +code.squareroundforest.org/arpio/wand/apply.go:151.28,153.4 1 1 +code.squareroundforest.org/arpio/wand/apply.go:155.3,155.27 1 1 +code.squareroundforest.org/arpio/wand/apply.go:158.2,158.32 1 1 +code.squareroundforest.org/arpio/wand/apply.go:161.63,165.2 3 1 +code.squareroundforest.org/arpio/wand/apply.go:167.93,170.33 3 1 +code.squareroundforest.org/arpio/wand/apply.go:170.33,174.28 4 1 +code.squareroundforest.org/arpio/wand/apply.go:174.28,175.69 1 0 +code.squareroundforest.org/arpio/wand/apply.go:175.69,177.5 1 0 +code.squareroundforest.org/arpio/wand/apply.go:178.9,178.23 1 1 +code.squareroundforest.org/arpio/wand/apply.go:178.23,181.4 2 1 +code.squareroundforest.org/arpio/wand/apply.go:181.9,181.22 1 1 +code.squareroundforest.org/arpio/wand/apply.go:181.22,182.33 1 1 +code.squareroundforest.org/arpio/wand/apply.go:182.33,184.5 1 1 +code.squareroundforest.org/arpio/wand/apply.go:185.9,189.4 3 1 +code.squareroundforest.org/arpio/wand/apply.go:192.2,192.13 1 1 +code.squareroundforest.org/arpio/wand/apply.go:195.73,196.19 1 1 +code.squareroundforest.org/arpio/wand/apply.go:196.19,198.3 1 0 +code.squareroundforest.org/arpio/wand/apply.go:200.2,203.40 4 1 +code.squareroundforest.org/arpio/wand/apply.go:203.40,205.3 1 0 +code.squareroundforest.org/arpio/wand/apply.go:207.2,207.17 1 1 +code.squareroundforest.org/arpio/wand/apply.go:207.17,209.3 1 0 +code.squareroundforest.org/arpio/wand/apply.go:211.2,212.24 2 1 +code.squareroundforest.org/arpio/wand/apply.go:212.24,214.3 1 1 +code.squareroundforest.org/arpio/wand/apply.go:216.2,216.20 1 1 +code.squareroundforest.org/arpio/wand/apply.go:219.59,226.2 6 1 +code.squareroundforest.org/arpio/wand/command.go:9.57,15.2 1 1 +code.squareroundforest.org/arpio/wand/command.go:17.25,19.8 2 1 +code.squareroundforest.org/arpio/wand/command.go:19.8,21.3 1 1 +code.squareroundforest.org/arpio/wand/command.go:23.2,23.26 1 1 +code.squareroundforest.org/arpio/wand/command.go:26.38,28.23 2 1 +code.squareroundforest.org/arpio/wand/command.go:28.23,29.68 1 1 +code.squareroundforest.org/arpio/wand/command.go:29.68,31.4 1 0 +code.squareroundforest.org/arpio/wand/command.go:33.3,33.19 1 1 +code.squareroundforest.org/arpio/wand/command.go:36.2,36.12 1 1 +code.squareroundforest.org/arpio/wand/command.go:39.46,40.18 1 1 +code.squareroundforest.org/arpio/wand/command.go:54.18,55.13 1 1 +code.squareroundforest.org/arpio/wand/command.go:57.17,59.30 2 0 +code.squareroundforest.org/arpio/wand/command.go:60.25,61.24 1 0 +code.squareroundforest.org/arpio/wand/command.go:61.24,63.4 1 0 +code.squareroundforest.org/arpio/wand/command.go:65.3,65.13 1 0 +code.squareroundforest.org/arpio/wand/command.go:66.10,67.57 1 0 +code.squareroundforest.org/arpio/wand/command.go:71.61,73.23 2 1 +code.squareroundforest.org/arpio/wand/command.go:73.23,74.47 1 1 +code.squareroundforest.org/arpio/wand/command.go:74.47,76.4 1 0 +code.squareroundforest.org/arpio/wand/command.go:79.2,81.18 3 1 +code.squareroundforest.org/arpio/wand/command.go:81.18,83.3 1 1 +code.squareroundforest.org/arpio/wand/command.go:85.2,85.38 1 1 +code.squareroundforest.org/arpio/wand/command.go:85.38,91.3 1 0 +code.squareroundforest.org/arpio/wand/command.go:93.2,93.55 1 1 +code.squareroundforest.org/arpio/wand/command.go:93.55,99.3 1 0 +code.squareroundforest.org/arpio/wand/command.go:101.2,101.38 1 1 +code.squareroundforest.org/arpio/wand/command.go:101.38,107.3 1 0 +code.squareroundforest.org/arpio/wand/command.go:109.2,109.37 1 1 +code.squareroundforest.org/arpio/wand/command.go:109.37,115.3 1 0 +code.squareroundforest.org/arpio/wand/command.go:117.2,117.12 1 1 +code.squareroundforest.org/arpio/wand/command.go:120.34,124.30 4 1 +code.squareroundforest.org/arpio/wand/command.go:124.30,126.3 1 0 +code.squareroundforest.org/arpio/wand/command.go:128.2,130.42 3 1 +code.squareroundforest.org/arpio/wand/command.go:130.42,132.3 1 0 +code.squareroundforest.org/arpio/wand/command.go:134.2,134.84 1 1 +code.squareroundforest.org/arpio/wand/command.go:134.84,136.3 1 0 +code.squareroundforest.org/arpio/wand/command.go:138.2,138.12 1 1 +code.squareroundforest.org/arpio/wand/command.go:141.40,144.32 3 1 +code.squareroundforest.org/arpio/wand/command.go:144.32,148.3 1 0 +code.squareroundforest.org/arpio/wand/command.go:150.2,150.46 1 1 +code.squareroundforest.org/arpio/wand/command.go:150.46,153.27 3 1 +code.squareroundforest.org/arpio/wand/command.go:153.27,155.4 1 0 +code.squareroundforest.org/arpio/wand/command.go:157.3,157.51 1 1 +code.squareroundforest.org/arpio/wand/command.go:157.51,159.4 1 0 +code.squareroundforest.org/arpio/wand/command.go:161.3,161.26 1 1 +code.squareroundforest.org/arpio/wand/command.go:161.26,163.4 1 0 +code.squareroundforest.org/arpio/wand/command.go:165.3,165.39 1 1 +code.squareroundforest.org/arpio/wand/command.go:165.39,167.4 1 0 +code.squareroundforest.org/arpio/wand/command.go:169.3,169.14 1 1 +code.squareroundforest.org/arpio/wand/command.go:172.2,172.12 1 1 +code.squareroundforest.org/arpio/wand/command.go:175.37,176.19 1 1 +code.squareroundforest.org/arpio/wand/command.go:176.19,178.6 1 1 +code.squareroundforest.org/arpio/wand/command.go:180.2,180.21 1 1 +code.squareroundforest.org/arpio/wand/command.go:180.21,181.43 1 1 +code.squareroundforest.org/arpio/wand/command.go:181.43,183.4 1 0 +code.squareroundforest.org/arpio/wand/command.go:186.2,186.50 1 1 +code.squareroundforest.org/arpio/wand/command.go:186.50,188.3 1 0 +code.squareroundforest.org/arpio/wand/command.go:190.5,190.24 1 1 +code.squareroundforest.org/arpio/wand/command.go:190.24,191.55 1 1 +code.squareroundforest.org/arpio/wand/command.go:191.55,193.10 1 0 +code.squareroundforest.org/arpio/wand/command.go:196.5,198.36 3 1 +code.squareroundforest.org/arpio/wand/command.go:198.36,199.19 1 1 +code.squareroundforest.org/arpio/wand/command.go:199.19,201.4 1 0 +code.squareroundforest.org/arpio/wand/command.go:203.3,203.20 1 1 +code.squareroundforest.org/arpio/wand/command.go:203.20,205.4 1 0 +code.squareroundforest.org/arpio/wand/command.go:207.3,208.44 2 1 +code.squareroundforest.org/arpio/wand/command.go:208.44,210.4 1 0 +code.squareroundforest.org/arpio/wand/command.go:212.9,212.38 1 1 +code.squareroundforest.org/arpio/wand/command.go:212.38,214.10 1 0 +code.squareroundforest.org/arpio/wand/command.go:216.9,216.24 1 1 +code.squareroundforest.org/arpio/wand/command.go:216.24,218.10 1 1 +code.squareroundforest.org/arpio/wand/command.go:221.2,221.12 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:28.30,30.2 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:32.34,34.2 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:36.48,37.29 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:37.29,38.24 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:38.24,40.10 1 0 +code.squareroundforest.org/arpio/wand/commandline.go:43.5,43.33 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:46.56,47.35 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:47.35,48.22 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:48.22,50.10 1 0 +code.squareroundforest.org/arpio/wand/commandline.go:53.5,53.35 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:56.36,65.23 8 1 +code.squareroundforest.org/arpio/wand/commandline.go:65.23,67.3 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:69.5,71.46 3 1 +code.squareroundforest.org/arpio/wand/commandline.go:71.46,74.3 2 1 +code.squareroundforest.org/arpio/wand/commandline.go:76.2,77.23 2 1 +code.squareroundforest.org/arpio/wand/commandline.go:77.23,78.28 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:78.28,80.4 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:83.5,84.25 2 1 +code.squareroundforest.org/arpio/wand/commandline.go:87.32,89.17 2 1 +code.squareroundforest.org/arpio/wand/commandline.go:89.17,91.3 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:93.2,93.27 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:93.27,95.3 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:97.2,97.28 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:97.28,99.3 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:101.2,101.26 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:101.26,102.25 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:102.25,103.12 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:106.3,106.25 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:106.25,107.12 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:110.3,110.15 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:110.15,111.12 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:114.3,114.15 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:114.15,116.4 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:118.3,118.15 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:121.2,121.13 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:124.40,126.16 2 1 +code.squareroundforest.org/arpio/wand/commandline.go:126.16,128.3 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:130.2,130.17 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:130.17,132.3 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:134.2,134.28 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:134.28,136.3 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:138.2,138.26 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:138.26,139.15 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:139.15,141.4 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:143.3,143.26 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:143.26,145.4 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:148.2,148.13 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:151.34,152.24 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:152.24,154.6 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:156.5,156.40 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:156.40,157.25 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:157.25,159.10 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:162.5,162.15 1 0 +code.squareroundforest.org/arpio/wand/commandline.go:165.51,166.40 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:166.40,167.28 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:167.28,169.10 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:172.5,172.24 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:175.70,176.23 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:176.23,178.6 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:180.5,181.12 2 1 +code.squareroundforest.org/arpio/wand/commandline.go:181.12,183.6 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:185.5,185.18 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:185.18,188.6 2 0 +code.squareroundforest.org/arpio/wand/commandline.go:190.5,192.34 3 1 +code.squareroundforest.org/arpio/wand/commandline.go:195.49,200.2 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:202.46,207.2 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:209.33,212.2 2 1 +code.squareroundforest.org/arpio/wand/commandline.go:214.34,215.17 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:215.17,217.3 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:219.2,219.19 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:219.19,221.3 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:223.2,223.27 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:223.27,225.3 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:227.2,227.13 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:230.38,233.2 2 1 +code.squareroundforest.org/arpio/wand/commandline.go:235.85,237.14 2 1 +code.squareroundforest.org/arpio/wand/commandline.go:237.14,239.65 2 1 +code.squareroundforest.org/arpio/wand/commandline.go:239.65,242.4 2 1 +code.squareroundforest.org/arpio/wand/commandline.go:244.3,244.40 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:247.2,252.19 2 1 +code.squareroundforest.org/arpio/wand/commandline.go:252.19,256.3 3 1 +code.squareroundforest.org/arpio/wand/commandline.go:258.2,258.39 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:258.39,260.25 2 1 +code.squareroundforest.org/arpio/wand/commandline.go:260.25,263.4 2 1 +code.squareroundforest.org/arpio/wand/commandline.go:265.3,265.38 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:268.2,268.21 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:268.21,270.3 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:272.2,272.42 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:275.95,278.14 3 1 +code.squareroundforest.org/arpio/wand/commandline.go:278.14,280.3 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:282.2,283.31 2 1 +code.squareroundforest.org/arpio/wand/commandline.go:283.31,285.3 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:287.2,287.94 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:287.94,290.3 2 1 +code.squareroundforest.org/arpio/wand/commandline.go:292.2,295.16 4 1 +code.squareroundforest.org/arpio/wand/commandline.go:298.55,300.20 2 1 +code.squareroundforest.org/arpio/wand/commandline.go:300.20,302.3 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:304.2,305.9 2 1 +code.squareroundforest.org/arpio/wand/commandline.go:306.19,307.20 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:307.20,311.4 3 1 +code.squareroundforest.org/arpio/wand/commandline.go:312.21,316.35 4 1 +code.squareroundforest.org/arpio/wand/commandline.go:317.29,321.38 4 1 +code.squareroundforest.org/arpio/wand/commandline.go:322.10,323.43 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:326.2,329.10 4 1 +code.squareroundforest.org/arpio/wand/commandline.go:332.46,334.24 2 1 +code.squareroundforest.org/arpio/wand/commandline.go:334.24,336.6 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:338.5,339.49 2 1 +code.squareroundforest.org/arpio/wand/commandline.go:339.49,342.6 2 1 +code.squareroundforest.org/arpio/wand/commandline.go:344.5,344.26 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:344.26,345.29 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:345.29,346.21 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:349.9,349.30 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:349.30,350.41 1 0 +code.squareroundforest.org/arpio/wand/commandline.go:350.41,352.14 1 0 +code.squareroundforest.org/arpio/wand/commandline.go:355.9,355.43 1 1 +code.squareroundforest.org/arpio/wand/commandline.go:355.43,356.25 1 0 +code.squareroundforest.org/arpio/wand/commandline.go:356.25,358.14 1 0 +code.squareroundforest.org/arpio/wand/commandline.go:362.5,362.17 1 1 +code.squareroundforest.org/arpio/wand/env.go:13.39,20.30 2 1 +code.squareroundforest.org/arpio/wand/env.go:20.30,21.13 1 1 +code.squareroundforest.org/arpio/wand/env.go:21.13,24.12 3 1 +code.squareroundforest.org/arpio/wand/env.go:27.3,27.16 1 1 +code.squareroundforest.org/arpio/wand/env.go:27.16,29.12 2 1 +code.squareroundforest.org/arpio/wand/env.go:32.3,32.15 1 1 +code.squareroundforest.org/arpio/wand/env.go:32.15,35.12 3 1 +code.squareroundforest.org/arpio/wand/env.go:38.3,38.31 1 1 +code.squareroundforest.org/arpio/wand/env.go:41.2,42.15 2 1 +code.squareroundforest.org/arpio/wand/env.go:45.50,52.26 3 1 +code.squareroundforest.org/arpio/wand/env.go:52.26,54.22 2 1 +code.squareroundforest.org/arpio/wand/env.go:54.22,55.12 1 1 +code.squareroundforest.org/arpio/wand/env.go:58.3,60.73 3 1 +code.squareroundforest.org/arpio/wand/env.go:60.73,61.12 1 1 +code.squareroundforest.org/arpio/wand/env.go:64.3,66.39 3 1 +code.squareroundforest.org/arpio/wand/env.go:69.2,69.10 1 1 +code.squareroundforest.org/arpio/wand/exec.go:11.82,15.45 4 1 +code.squareroundforest.org/arpio/wand/exec.go:15.45,16.13 1 0 +code.squareroundforest.org/arpio/wand/exec.go:19.2,22.21 4 1 +code.squareroundforest.org/arpio/wand/exec.go:22.21,27.3 4 0 +code.squareroundforest.org/arpio/wand/exec.go:29.5,29.26 1 1 +code.squareroundforest.org/arpio/wand/exec.go:29.26,32.6 2 0 +code.squareroundforest.org/arpio/wand/exec.go:34.2,36.39 3 1 +code.squareroundforest.org/arpio/wand/exec.go:36.39,39.6 2 0 +code.squareroundforest.org/arpio/wand/exec.go:41.2,41.50 1 1 +code.squareroundforest.org/arpio/wand/exec.go:41.50,46.3 4 1 +code.squareroundforest.org/arpio/wand/exec.go:48.2,49.16 2 1 +code.squareroundforest.org/arpio/wand/exec.go:49.16,53.3 3 0 +code.squareroundforest.org/arpio/wand/exec.go:55.2,55.52 1 1 +code.squareroundforest.org/arpio/wand/exec.go:55.52,59.3 3 0 +code.squareroundforest.org/arpio/wand/help.go:9.17,14.2 1 1 +code.squareroundforest.org/arpio/wand/help.go:16.30,18.40 2 1 +code.squareroundforest.org/arpio/wand/help.go:18.40,20.31 2 1 +code.squareroundforest.org/arpio/wand/help.go:20.31,22.10 1 0 +code.squareroundforest.org/arpio/wand/help.go:25.5,25.20 1 1 +code.squareroundforest.org/arpio/wand/help.go:25.20,27.6 1 1 +code.squareroundforest.org/arpio/wand/help.go:29.5,29.15 1 1 +code.squareroundforest.org/arpio/wand/help.go:32.38,33.40 1 1 +code.squareroundforest.org/arpio/wand/help.go:33.40,34.22 1 1 +code.squareroundforest.org/arpio/wand/help.go:34.22,36.10 1 1 +code.squareroundforest.org/arpio/wand/help.go:39.5,39.17 1 0 +code.squareroundforest.org/arpio/wand/help.go:42.40,46.2 3 0 +code.squareroundforest.org/arpio/wand/help.go:48.64,49.31 1 1 +code.squareroundforest.org/arpio/wand/help.go:49.31,52.6 2 1 +code.squareroundforest.org/arpio/wand/help.go:54.5,54.34 1 0 +code.squareroundforest.org/arpio/wand/help.go:54.34,57.6 2 0 +code.squareroundforest.org/arpio/wand/help.go:60.62,61.2 0 0 +code.squareroundforest.org/arpio/wand/input.go:8.40,10.37 2 1 +code.squareroundforest.org/arpio/wand/input.go:10.37,12.10 2 1 +code.squareroundforest.org/arpio/wand/input.go:12.10,13.12 1 0 +code.squareroundforest.org/arpio/wand/input.go:16.3,16.24 1 1 +code.squareroundforest.org/arpio/wand/input.go:16.24,17.46 1 1 +code.squareroundforest.org/arpio/wand/input.go:17.46,23.5 1 0 +code.squareroundforest.org/arpio/wand/input.go:25.4,25.29 1 1 +code.squareroundforest.org/arpio/wand/input.go:25.29,26.28 1 1 +code.squareroundforest.org/arpio/wand/input.go:26.28,31.6 1 1 +code.squareroundforest.org/arpio/wand/input.go:36.2,36.12 1 1 +code.squareroundforest.org/arpio/wand/input.go:39.49,42.46 3 1 +code.squareroundforest.org/arpio/wand/input.go:42.46,46.3 3 1 +code.squareroundforest.org/arpio/wand/input.go:48.2,49.23 2 1 +code.squareroundforest.org/arpio/wand/input.go:49.23,51.42 2 1 +code.squareroundforest.org/arpio/wand/input.go:51.42,53.4 1 1 +code.squareroundforest.org/arpio/wand/input.go:55.3,55.28 1 1 +code.squareroundforest.org/arpio/wand/input.go:58.2,59.24 2 1 +code.squareroundforest.org/arpio/wand/input.go:59.24,61.24 2 1 +code.squareroundforest.org/arpio/wand/input.go:61.24,63.27 2 1 +code.squareroundforest.org/arpio/wand/input.go:63.27,65.5 1 1 +code.squareroundforest.org/arpio/wand/input.go:67.4,67.42 1 1 +code.squareroundforest.org/arpio/wand/input.go:67.42,73.5 1 0 +code.squareroundforest.org/arpio/wand/input.go:75.4,75.26 1 1 +code.squareroundforest.org/arpio/wand/input.go:75.26,76.57 1 1 +code.squareroundforest.org/arpio/wand/input.go:76.57,81.6 1 1 +code.squareroundforest.org/arpio/wand/input.go:83.5,83.59 1 1 +code.squareroundforest.org/arpio/wand/input.go:83.59,88.6 1 0 +code.squareroundforest.org/arpio/wand/input.go:93.2,93.12 1 1 +code.squareroundforest.org/arpio/wand/input.go:96.56,104.18 8 1 +code.squareroundforest.org/arpio/wand/input.go:104.18,107.3 2 1 +code.squareroundforest.org/arpio/wand/input.go:109.2,109.29 1 1 +code.squareroundforest.org/arpio/wand/input.go:109.29,111.3 1 0 +code.squareroundforest.org/arpio/wand/input.go:113.2,113.27 1 1 +code.squareroundforest.org/arpio/wand/input.go:113.27,115.3 1 0 +code.squareroundforest.org/arpio/wand/input.go:117.2,117.18 1 1 +code.squareroundforest.org/arpio/wand/input.go:117.18,119.3 1 0 +code.squareroundforest.org/arpio/wand/input.go:121.2,121.30 1 1 +code.squareroundforest.org/arpio/wand/input.go:121.30,123.3 1 0 +code.squareroundforest.org/arpio/wand/input.go:125.2,125.23 1 1 +code.squareroundforest.org/arpio/wand/input.go:125.23,127.18 2 1 +code.squareroundforest.org/arpio/wand/input.go:127.18,129.4 1 1 +code.squareroundforest.org/arpio/wand/input.go:129.9,131.4 1 1 +code.squareroundforest.org/arpio/wand/input.go:133.3,133.23 1 1 +code.squareroundforest.org/arpio/wand/input.go:133.23,139.4 1 0 +code.squareroundforest.org/arpio/wand/input.go:142.2,142.12 1 1 +code.squareroundforest.org/arpio/wand/input.go:145.58,146.44 1 1 +code.squareroundforest.org/arpio/wand/input.go:146.44,148.3 1 1 +code.squareroundforest.org/arpio/wand/input.go:150.2,150.57 1 1 +code.squareroundforest.org/arpio/wand/input.go:150.57,152.3 1 1 +code.squareroundforest.org/arpio/wand/input.go:154.2,154.67 1 1 +code.squareroundforest.org/arpio/wand/input.go:154.67,156.3 1 0 +code.squareroundforest.org/arpio/wand/input.go:158.2,158.12 1 1 +code.squareroundforest.org/arpio/wand/output.go:8.46,9.23 1 1 +code.squareroundforest.org/arpio/wand/output.go:9.23,11.9 2 1 +code.squareroundforest.org/arpio/wand/output.go:11.9,12.43 1 0 +code.squareroundforest.org/arpio/wand/output.go:12.43,14.5 1 0 +code.squareroundforest.org/arpio/wand/output.go:16.4,16.12 1 0 +code.squareroundforest.org/arpio/wand/output.go:19.3,19.55 1 1 +code.squareroundforest.org/arpio/wand/output.go:19.55,21.4 1 0 +code.squareroundforest.org/arpio/wand/output.go:24.2,24.12 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:20.58,21.19 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:21.19,23.3 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:25.2,25.33 1 0 +code.squareroundforest.org/arpio/wand/reflect.go:25.33,30.3 4 0 +code.squareroundforest.org/arpio/wand/reflect.go:32.2,35.10 4 0 +code.squareroundforest.org/arpio/wand/reflect.go:38.37,39.18 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:41.17,42.26 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:43.10,44.11 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:48.36,51.2 2 1 +code.squareroundforest.org/arpio/wand/reflect.go:53.45,54.18 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:55.20,57.20 2 0 +code.squareroundforest.org/arpio/wand/reflect.go:58.78,60.20 2 1 +code.squareroundforest.org/arpio/wand/reflect.go:61.83,63.20 2 0 +code.squareroundforest.org/arpio/wand/reflect.go:64.40,66.20 2 0 +code.squareroundforest.org/arpio/wand/reflect.go:67.22,68.14 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:69.10,70.15 1 0 +code.squareroundforest.org/arpio/wand/reflect.go:74.41,76.18 2 1 +code.squareroundforest.org/arpio/wand/reflect.go:77.20,79.46 2 0 +code.squareroundforest.org/arpio/wand/reflect.go:80.78,82.46 2 1 +code.squareroundforest.org/arpio/wand/reflect.go:83.83,85.46 2 0 +code.squareroundforest.org/arpio/wand/reflect.go:86.40,88.46 2 0 +code.squareroundforest.org/arpio/wand/reflect.go:89.10,90.46 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:93.2,93.29 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:96.40,97.17 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:97.17,99.3 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:101.2,106.39 2 1 +code.squareroundforest.org/arpio/wand/reflect.go:106.39,113.21 7 1 +code.squareroundforest.org/arpio/wand/reflect.go:127.19,128.86 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:129.26,130.28 1 0 +code.squareroundforest.org/arpio/wand/reflect.go:130.28,132.5 1 0 +code.squareroundforest.org/arpio/wand/reflect.go:133.23,135.20 2 0 +code.squareroundforest.org/arpio/wand/reflect.go:135.20,137.5 1 0 +code.squareroundforest.org/arpio/wand/reflect.go:137.10,138.24 1 0 +code.squareroundforest.org/arpio/wand/reflect.go:138.24,141.6 2 0 +code.squareroundforest.org/arpio/wand/reflect.go:143.5,143.46 1 0 +code.squareroundforest.org/arpio/wand/reflect.go:148.2,149.32 2 1 +code.squareroundforest.org/arpio/wand/reflect.go:149.32,151.3 1 0 +code.squareroundforest.org/arpio/wand/reflect.go:153.2,153.33 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:153.33,155.3 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:157.2,158.24 2 1 +code.squareroundforest.org/arpio/wand/reflect.go:158.24,160.3 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:162.2,162.39 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:165.36,167.23 2 1 +code.squareroundforest.org/arpio/wand/reflect.go:167.23,168.36 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:168.36,170.4 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:173.2,173.10 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:176.45,183.23 7 1 +code.squareroundforest.org/arpio/wand/reflect.go:183.23,185.3 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:187.2,187.11 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:190.81,192.33 2 1 +code.squareroundforest.org/arpio/wand/reflect.go:192.33,195.11 3 1 +code.squareroundforest.org/arpio/wand/reflect.go:195.11,197.4 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:200.2,200.10 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:203.58,204.55 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:204.55,206.3 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:209.54,210.55 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:210.55,212.3 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:215.46,216.16 1 0 +code.squareroundforest.org/arpio/wand/reflect.go:216.16,218.3 1 0 +code.squareroundforest.org/arpio/wand/reflect.go:220.2,222.19 3 0 +code.squareroundforest.org/arpio/wand/reflect.go:223.20,224.35 1 0 +code.squareroundforest.org/arpio/wand/reflect.go:225.78,226.20 1 0 +code.squareroundforest.org/arpio/wand/reflect.go:227.79,228.15 1 0 +code.squareroundforest.org/arpio/wand/reflect.go:229.11,230.16 1 0 +code.squareroundforest.org/arpio/wand/reflect.go:232.83,233.20 1 0 +code.squareroundforest.org/arpio/wand/reflect.go:234.84,235.15 1 0 +code.squareroundforest.org/arpio/wand/reflect.go:236.11,237.16 1 0 +code.squareroundforest.org/arpio/wand/reflect.go:239.40,240.20 1 0 +code.squareroundforest.org/arpio/wand/reflect.go:241.41,242.15 1 0 +code.squareroundforest.org/arpio/wand/reflect.go:243.11,244.16 1 0 +code.squareroundforest.org/arpio/wand/reflect.go:246.22,247.37 1 0 +code.squareroundforest.org/arpio/wand/reflect.go:248.25,249.86 1 0 +code.squareroundforest.org/arpio/wand/reflect.go:250.10,251.15 1 0 +code.squareroundforest.org/arpio/wand/reflect.go:255.43,256.31 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:256.31,258.3 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:260.2,260.18 1 1 +code.squareroundforest.org/arpio/wand/reflect.go:261.38,262.35 1 0 +code.squareroundforest.org/arpio/wand/reflect.go:263.10,264.15 1 1 +code.squareroundforest.org/arpio/wand/wand.go:17.57,19.2 1 1 +code.squareroundforest.org/arpio/wand/wand.go:21.27,24.2 2 1 +code.squareroundforest.org/arpio/wand/wand.go:26.39,31.2 4 0 +code.squareroundforest.org/arpio/wand/wand.go:33.43,37.2 3 1 +code.squareroundforest.org/arpio/wand/wand.go:39.21,42.2 2 0 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e43c2e8 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +SOURCES = $(shell find . -name "*.go") + +default: build + +build: $(SOURCES) + go build ./... + +check: $(SOURCES) + go test -count 1 ./... + +.cover: $(SOURCES) + go test -count 1 -coverprofile .cover ./... + +cover: .cover + go tool cover -func .cover + +showcover: .cover + go tool cover -html .cover + +fmt: $(SOURCES) + go fmt ./... diff --git a/apply.go b/apply.go new file mode 100644 index 0000000..cadf808 --- /dev/null +++ b/apply.go @@ -0,0 +1,234 @@ +package wand + +import ( + "github.com/iancoleman/strcase" + "reflect" + "strings" + "os" +) + +func ensurePointerAllocation(p reflect.Value, n int) { + if p.IsNil() { + p.Set(reflect.New(p.Type().Elem())) + } + + ensureAllocation(p.Elem(), n) +} + +func ensureSliceAllocation(s reflect.Value, n int) { + if s.Len() < n { + a := reflect.MakeSlice(s.Type(), n-s.Len(), n-s.Len()) + a = reflect.AppendSlice(s, a) + s.Set(a) + } + + if s.Len() > n { + a := s.Slice(0, n) + s.Set(a) + } + + for i := 0; i < s.Len(); i++ { + ensureAllocation(s.Index(i), 1) + } +} + +func ensureAllocation(v reflect.Value, n int) { + switch v.Type().Kind() { + case reflect.Pointer: + ensurePointerAllocation(v, n) + case reflect.Slice: + ensureSliceAllocation(v, n) + } +} + +func setPointerValue(p reflect.Value, v []value) { + setFieldValue(p.Elem(), v) +} + +func setSliceValue(s reflect.Value, v []value) { + for i := 0; i < s.Len(); i++ { + setFieldValue(s.Index(i), v[i:i+1]) + } +} + +func setValue(f reflect.Value, v value) { + if v.isBool { + f.Set(reflect.ValueOf(v.boolean)) + return + } + + f.Set(reflect.ValueOf(scan(f.Type(), v.str))) +} + +func setFieldValue(field reflect.Value, v []value) { + switch field.Kind() { + case reflect.Pointer: + setPointerValue(field, v) + case reflect.Slice: + setSliceValue(field, v) + default: + setValue(field, v[0]) + } +} + +func setField(s reflect.Value, name string, v []value) { + for i := 0; i < s.Type().NumField(); i++ { + fs := s.Type().Field(i) + fname := strcase.ToKebab(fs.Name) + ft := fs.Type + ftup := unpack(ft) + fv := s.Field(i) + switch { + case !fs.Anonymous && fname == name: + ensureAllocation(fv, len(v)) + setFieldValue(fv, v) + case !fs.Anonymous && ftup.Kind() == reflect.Struct: + prefix := fname + "-" + if strings.HasPrefix(name, prefix) { + ensureAllocation(fv, len(v)) + setField(unpack(fv), name[len(prefix):], v) + } + case fs.Anonymous: + ensureAllocation(fv, 1) + setField(unpack(fv), name, v) + } + } +} + +func createStructArg(t reflect.Type, shortForms []string, e env, o []option) (reflect.Value, bool) { + tup := unpack(t) + f := fields(tup) + fn := make(map[string]bool) + for _, fi := range f { + fn[fi.name] = true + } + + ms := make(map[string]string) + for i := 0; i < len(shortForms); i += 2 { + l, s := shortForms[i], shortForms[i+1] + ms[s] = l + } + + om := make(map[string][]option) + for _, oi := range o { + n := oi.name + if l, ok := ms[n]; ok && oi.shortForm { + n = l + } + + om[n] = append(om[n], oi) + } + + var foundEnv []string + for n := range e.values { + if fn[n] { + foundEnv = append(foundEnv, n) + } + } + + var foundOptions []string + for n := range om { + if fn[n] { + foundOptions = append(foundOptions, n) + } + } + + if len(foundEnv) == 0 && len(foundOptions) == 0 { + return reflect.Zero(t), false + } + + p := reflect.New(tup) + for _, n := range foundEnv { + var v []value + for _, vi := range e.values[n] { + v = append(v, stringValue(vi)) + } + + setField(p.Elem(), n, v) + } + + for _, n := range foundOptions { + var v []value + for _, oi := range om[n] { + v = append(v, oi.value) + } + + setField(p.Elem(), n, v) + } + + return pack(p.Elem(), t), true +} + +func createPositional(t reflect.Type, v string) reflect.Value { + tup := unpack(t) + sv := reflect.ValueOf(scan(tup, v)) + return pack(sv, t) +} + +func createArgs(t reflect.Type, shortForms []string, e env, cl commandLine) []reflect.Value { + var args []reflect.Value + positional := cl.positional + for i := 0; i < t.NumIn(); i++ { + ti := t.In(i) + structure := isStruct(ti) + variadic := t.IsVariadic() && i == t.NumIn()-1 + ior := isReader(ti) + iow := isWriter(ti) + switch { + case ior: + args = append(args, reflect.ValueOf(os.Stdin)) + case iow: + args = append(args, reflect.ValueOf(os.Stdout)) + case structure && variadic: + if arg, ok := createStructArg(ti, shortForms, e, cl.options); ok { + args = append(args, arg) + } + case structure: + arg, _ := createStructArg(ti, shortForms, e, cl.options) + args = append(args, arg) + case variadic: + for _, p := range positional { + args = append(args, createPositional(ti.Elem(), p)) + } + default: + var p string + p, positional = positional[0], positional[1:] + args = append(args, createPositional(ti, p)) + } + } + + return args +} + +func processResults(t reflect.Type, out []reflect.Value) ([]any, error) { + if len(out) == 0 { + return nil, nil + } + + var err error + last := len(out) - 1 + isErrorType := t.Out(last) == reflect.TypeOf(err) + if isErrorType && !out[last].IsZero() { + err = out[last].Interface().(error) + } + + if isErrorType { + out = out[:last] + } + + var values []any + for _, o := range out { + values = append(values, o.Interface()) + } + + return values, err +} + +func apply(cmd Cmd, e env, cl commandLine) ([]any, error) { + v := reflect.ValueOf(cmd.impl) + v = unpack(v) + t := v.Type() + args := createArgs(t, cmd.shortForms, e, cl) + out := v.Call(args) + return processResults(t, out) +} diff --git a/cmd/wand-docs/main.go b/cmd/wand-docs/main.go new file mode 100644 index 0000000..aab578d --- /dev/null +++ b/cmd/wand-docs/main.go @@ -0,0 +1,12 @@ +package main + +// myFunc is. +func myFunc() { +} + +// MyFunc is. +func MyFunc() { +} + +func main() { +} diff --git a/command.go b/command.go new file mode 100644 index 0000000..ce47ad4 --- /dev/null +++ b/command.go @@ -0,0 +1,236 @@ +package wand + +import ( + "errors" + "fmt" + "reflect" + "slices" +) + +func command(name string, impl any, subcmds ...Cmd) Cmd { + return Cmd{ + name: name, + impl: impl, + subcommands: subcmds, + } +} + +func wrap(impl any) Cmd { + cmd, ok := impl.(Cmd) + if ok { + return cmd + } + + return Command("", impl) +} + +func validateFields(f []field) error { + mf := make(map[string]field) + for _, fi := range f { + if ef, ok := mf[fi.name]; ok && !compatibleTypes(fi.typ, ef.typ) { + return fmt.Errorf("duplicate fields with different types: %s", fi.name) + } + + mf[fi.name] = fi + } + + return nil +} + +func validateParameter(t reflect.Type) error { + switch t.Kind() { + case reflect.Bool, + reflect.Int, + reflect.Int8, + reflect.Int16, + reflect.Int32, + reflect.Int64, + reflect.Uint, + reflect.Uint8, + reflect.Uint16, + reflect.Uint32, + reflect.Uint64, + reflect.Float32, + reflect.Float64, + reflect.String: + return nil + case reflect.Pointer, + reflect.Slice: + t = unpack(t) + return validateParameter(t) + case reflect.Interface: + if t.NumMethod() > 0 { + return errors.New("'non-empty' interface parameter") + } + + return nil + default: + return fmt.Errorf("unsupported parameter type: %v", t) + } +} + +func validatePositional(t reflect.Type, min, max int) error { + p := positionalParameters(t) + ior, iow := ioParameters(p) + if len(ior) > 1 || len(iow) > 1 { + return errors.New("only zero or one reader and zero or one writer parameters is supported") + } + + for i, pi := range p { + if slices.Contains(ior, i) || slices.Contains(iow, i) { + continue + } + + if err := validateParameter(pi); err != nil { + return err + } + } + + last := t.NumIn()-1 + lastVariadic := t.IsVariadic() && + !isStruct(t.In(last)) && + !slices.Contains(ior, last) && + !slices.Contains(iow, last) + fixedPositional := len(p) - len(ior) - len(iow) + if lastVariadic { + fixedPositional-- + } + + if min > 0 && min < fixedPositional { + return fmt.Errorf( + "minimum positional defined as %d but the implementation expects minimum %d fixed parameters", + min, + fixedPositional, + ) + } + + if min > 0 && min > fixedPositional && !lastVariadic { + return fmt.Errorf( + "minimum positional defined as %d but the implementation has only %d fixed parameters and no variadic parameter", + min, + fixedPositional, + ) + } + + if max > 0 && max < fixedPositional { + return fmt.Errorf( + "maximum positional defined as %d but the implementation expects minimum %d fixed parameters", + max, + fixedPositional, + ) + } + + if min > 0 && max > 0 && min > max { + return fmt.Errorf( + "minimum positional defined as larger then the maxmimum positional: %d > %d", + min, + max, + ) + } + + return nil +} + +func validateImpl(cmd Cmd) error { + v := reflect.ValueOf(cmd.impl) + v = unpack(v) + t := v.Type() + if t.Kind() != reflect.Func { + return errors.New("command implementation not a function") + } + + s := structParameters(t) + f := fields(s...) + if err := validateFields(f); err != nil { + return err + } + + if err := validatePositional(t, cmd.minPositional, cmd.maxPositional); err != nil { + return err + } + + return nil +} + +func validateShortForms(cmd Cmd) error { + mf := mapFields(cmd.impl) + ms := make(map[string]string) + if len(cmd.shortForms)%2 != 0 { + return fmt.Errorf( + "undefined option short form: %s", cmd.shortForms[len(cmd.shortForms)-1], + ) + } + + for i := 0; i < len(cmd.shortForms); i += 2 { + fn := cmd.shortForms[i] + sf := cmd.shortForms[i+1] + if _, ok := mf[fn]; !ok { + return fmt.Errorf("undefined field: %s", fn) + } + + if len(sf) != 1 && (sf[0] < 'a' || sf[0] > 'z') { + return fmt.Errorf("invalid short form: %s", sf) + } + + if _, ok := mf[sf]; ok { + return fmt.Errorf("short form shadowing field name: %s", sf) + } + + if lf, ok := ms[sf]; ok && lf != fn { + return fmt.Errorf("ambigous short form: %s", sf) + } + + ms[sf] = fn + } + + return nil +} + +func validateCommand(cmd Cmd) error { + if cmd.isHelp { + return nil + } + + if cmd.impl != nil { + if err := validateImpl(cmd); err != nil { + return fmt.Errorf("%s: %w", cmd.name, err) + } + } + + if cmd.impl == nil && len(cmd.subcommands) == 0 { + return fmt.Errorf("empty command category: %s", cmd.name) + } + + if cmd.impl != nil { + if err := validateShortForms(cmd); err != nil { + return fmt.Errorf("%s: %w", cmd.name, err) + } + } + + var hasDefault bool + names := make(map[string]bool) + for _, s := range cmd.subcommands { + if s.name == "" { + return fmt.Errorf("unnamed subcommand of: %s", cmd.name) + } + + if names[s.name] { + return fmt.Errorf("subcommand name conflict: %s", s.name) + } + + names[s.name] = true + if err := validateCommand(s); err != nil { + return fmt.Errorf("%s: %w", s.name, err) + } + + if s.isDefault && hasDefault { + return fmt.Errorf("multiple default subcommands for: %s", cmd.name) + } + + if s.isDefault { + hasDefault = true + } + } + + return nil +} diff --git a/commandline.go b/commandline.go new file mode 100644 index 0000000..fa334c0 --- /dev/null +++ b/commandline.go @@ -0,0 +1,363 @@ +package wand + +import ( + "reflect" + "slices" + "strconv" + "strings" + "unicode" +) + +type value struct { + isBool bool + boolean bool + str string +} + +type option struct { + name string + value value + shortForm bool +} + +type commandLine struct { + options []option + positional []string +} + +func boolValue(b bool) value { + return value{isBool: true, boolean: b} +} + +func stringValue(s string) value { + return value{str: s} +} + +func insertHelpOption(names []string) []string { + for _, n := range names { + if n == "help" { + return names + } + } + + return append(names, "help") +} + +func insertHelpShortForm(shortForms []string) []string { + for _, sf := range shortForms { + if sf == "h" { + return shortForms + } + } + + return append(shortForms, "h") +} + +func boolOptions(cmd Cmd) []string { + v := reflect.ValueOf(cmd.impl) + v = unpack(v) + t := v.Type() + s := structParameters(t) + f := fields(s...) + b := boolFields(f) + + var n []string + for _, fi := range b { + n = append(n, fi.name) + } + + n = insertHelpOption(n) + sfm := make(map[string][]string) + for i := 0; i < len(cmd.shortForms); i += 2 { + l, s := cmd.shortForms[i], cmd.shortForms[i+1] + sfm[l] = append(sfm[l], s) + } + + var sf []string + for _, ni := range n { + if sn, ok := sfm[ni]; ok { + sf = append(sf, sn...) + } + } + + sf = insertHelpShortForm(sf) + return append(n, sf...) +} + +func isOption(arg string) bool { + a := []rune(arg) + if len(a) <= 2 { + return false + } + + if string(a[:2]) != "--" { + return false + } + + if !unicode.IsLower(a[2]) { + return false + } + + for _, r := range a[3:] { + if unicode.IsLower(r) { + continue + } + + if unicode.IsDigit(r) { + continue + } + + if r == '-' { + continue + } + + if r == '=' { + return true + } + + return false + } + + return true +} + +func isShortOptionSet(arg string) bool { + a := []rune(arg) + if len(a) < 2 { + return false + } + + if a[0] != '-' { + return false + } + + if !unicode.IsLower(a[1]) { + return false + } + + for _, r := range a[2:] { + if r == '=' { + return true + } + + if !unicode.IsLower(r) { + return false + } + } + + return true +} + +func defaultCommand(cmd Cmd) Cmd { + if cmd.impl != nil { + return cmd + } + + for _, sc := range cmd.subcommands { + if sc.isDefault { + return sc + } + } + + return cmd +} + +func subcommand(cmd Cmd, name string) (Cmd, bool) { + for _, sc := range cmd.subcommands { + if sc.name == name { + return sc, true + } + } + + return Cmd{}, false +} + +func selectCommand(cmd Cmd, args []string) (Cmd, []string, []string) { + if len(args) == 0 { + return defaultCommand(cmd), []string{cmd.name}, nil + } + + sc, ok := subcommand(cmd, args[0]) + if !ok { + return defaultCommand(cmd), []string{cmd.name}, args + } + + if sc.isHelp { + cmd.helpRequested = true + return cmd, []string{cmd.name}, args[1:] + } + + cmd, fullCommand, args := selectCommand(sc, args[1:]) + fullCommand = append([]string{cmd.name}, fullCommand...) + return cmd, fullCommand, args +} + +func boolOption(name string, value bool) option { + return option{ + name: name, + value: boolValue(value), + } +} + +func stringOption(name, value string) option { + return option{ + name: name, + value: stringValue(value), + } +} + +func shortForm(o option) option { + o.shortForm = true + return o +} + +func canBeValue(arg string) bool { + if arg == "--" { + return false + } + + if isOption(arg) { + return false + } + + if isShortOptionSet(arg) { + return false + } + + return true +} + +func canBeBoolValue(arg string) bool { + _, err := strconv.ParseBool(arg) + return err == nil +} + +func readOption(boolOptions []string, arg string, args []string) (option, []string) { + eqi := strings.Index(arg, "=") + if eqi >= 0 { + arg, value := arg[:eqi], arg[eqi+1:] + if slices.Contains(boolOptions, arg) && canBeBoolValue(value) { + v, _ := strconv.ParseBool(value) + return boolOption(arg, v), args + } + + return stringOption(arg, value), args + } + + var ( + next string + nextCanBeValue, nextCanBeBoolValue bool + ) + + if len(args) > 0 { + next = args[0] + nextCanBeValue = canBeValue(next) + nextCanBeBoolValue = canBeBoolValue(next) + } + + if slices.Contains(boolOptions, arg) { + value := true + if nextCanBeBoolValue { + value, _ = strconv.ParseBool(next) + args = args[1:] + } + + return boolOption(arg, value), args + } + + if !nextCanBeValue { + return boolOption(arg, true), args + } + + return stringOption(arg, next), args[1:] +} + +func readShortOptionSet(boolOptions []string, arg string, args []string) ([]option, []string) { + last := len(arg) - 1 + eqi := strings.Index(arg, "=") + if eqi >= 0 { + last = eqi - 1 + } + + var o []option + for _, a := range arg[:last] { + o = append(o, shortForm(boolOption(string(a), true))) + } + + if slices.Contains(boolOptions, arg[last:]) && (len(args) == 0 || !canBeBoolValue(args[0])) { + o = append(o, shortForm(boolOption(arg[last:], true))) + return o, args + } + + var lastOption option + lastOption, args = readOption(boolOptions, arg[last:], args) + o = append(o, shortForm(lastOption)) + return o, args +} + +func readArgs(boolOptions, args []string) commandLine { + var c commandLine + if len(args) == 0 { + return c + } + + arg, args := args[0], args[1:] + switch { + case arg == "--": + if len(args) > 0 { + arg, args = args[0], args[1:] + c.positional = append(c.positional, arg) + args = append([]string{"--"}, args...) + } + case isOption(arg): + var f option + arg = arg[2:] + f, args = readOption(boolOptions, arg, args) + c.options = append(c.options, f) + case isShortOptionSet(arg): + var f []option + arg = arg[1:] + f, args = readShortOptionSet(boolOptions, arg, args) + c.options = append(c.options, f...) + default: + c.positional = append(c.positional, arg) + } + + cc := readArgs(boolOptions, args) + c.options = append(c.options, cc.options...) + c.positional = append(c.positional, cc.positional...) + return c +} + +func hasHelpOption(cmd Cmd, o []option) bool { + var mf map[string][]field + if cmd.impl != nil { + mf = mapFields(cmd.impl) + } + + sf := make(map[string]bool) + for i := 0; i < len(cmd.shortForms); i += 2 { + s := cmd.shortForms[i+1] + sf[s] = true + } + + for _, oi := range o { + if !oi.value.isBool { + continue + } + + if oi.name == "help" { + if _, ok := mf["help"]; !ok { + return true + } + } + + if oi.shortForm && oi.name == "h" { + if !sf["h"] { + return true + } + } + } + + return false +} diff --git a/commandline_test.go b/commandline_test.go new file mode 100644 index 0000000..8c89709 --- /dev/null +++ b/commandline_test.go @@ -0,0 +1,239 @@ +package wand + +import ( + "fmt" + "strings" + "testing" +) + +func TestCommand(t *testing.T) { + type f struct { + One string + SecondField int + } + ff := func(f f) string { + return f.One + fmt.Sprint(f.SecondField) + } + + type b struct{ One, Two, Three, Four bool } + fb := func(b b) string { + return fmt.Sprintf("%t;%t;%t;%t", b.One, b.Two, b.Three, b.Four) + } + + fbp := func(b b, p ...string) string { + o := fb(b) + if len(p) == 0 { + return o + } + + s := []string{o} + for _, pi := range p { + s = append(s, pi) + } + + return strings.Join(s, ";") + } + + type m struct { + One, Two bool + Three string + } + fm := func(m m) string { + return fmt.Sprintf("%t;%t;%s", m.One, m.Two, m.Three) + } + + type l struct { + One []bool + Two []string + } + fl := func(l l) string { + var sb []string + for _, b := range l.One { + sb = append(sb, fmt.Sprint(b)) + } + + return strings.Join([]string{strings.Join(sb, ","), strings.Join(l.Two, ",")}, ";") + } + + type lb struct{ One, Two, Three []bool } + flb := func(lb lb) string { + var s []string + for _, b := range [][]bool{lb.One, lb.Two, lb.Three} { + var sb []string + for _, bi := range b { + sb = append(sb, fmt.Sprint(bi)) + } + + s = append(s, strings.Join(sb, ",")) + } + + return strings.Join(s, ";") + } + + fp := func(f f, a ...string) string { + o := ff(f) + return fmt.Sprintf("%s;%s", o, strings.Join(a, ",")) + } + + type d struct{ One2 bool } + fd := func(d d) string { + return fmt.Sprint(d.One2) + } + + t.Run("no args", testExec(t, ff, "", "foo", "", "0")) + t.Run("basic options", func(t *testing.T) { + t.Run("space", testExec(t, ff, "", "foo --one baz --second-field 42", "", "baz42")) + t.Run("eq", testExec(t, ff, "", "foo --one=baz --second-field=42", "", "baz42")) + }) + + t.Run("short options combined, explicit last", func(t *testing.T) { + t.Run("bool last", testExec(t, ShortForm(fb, "one", "a", "two", "b", "three", "c"), "", "foo -abc true", "", "true;true;true;false")) + t.Run("string last", testExec(t, ShortForm(fm, "one", "a", "two", "b", "three", "c"), "", "foo -abc bar", "", "true;true;bar")) + }) + + t.Run("multiple values", func(t *testing.T) { + t.Run("bools, short", testExec(t, ShortForm(fl, "one", "a"), "", "foo -a -a -a", "", "true,true,true;")) + t.Run("bools, short, combined", testExec(t, ShortForm(fl, "one", "a"), "", "foo -aaa", "", "true,true,true;")) + t.Run("bools, short, explicit", testExec(t, ShortForm(fl, "one", "a"), "", "foo -a true -a true -a true", "", "true,true,true;")) + t.Run("bools, short, combined, last explicit", testExec(t, ShortForm(fl, "one", "a"), "", "foo -aaa true", "", "true,true,true;")) + t.Run("bools, long", testExec(t, fl, "", "foo --one --one --one", "", "true,true,true;")) + t.Run("bools, long, explicit", testExec(t, fl, "", "foo --one true --one true --one true", "", "true,true,true;")) + t.Run("mixd, short", testExec(t, ShortForm(fl, "one", "a", "two", "b"), "", "foo -a -b bar", "", "true;bar")) + t.Run("mixed, short, combined", testExec(t, ShortForm(fl, "one", "a", "two", "b"), "", "foo -ab bar", "", "true;bar")) + t.Run("mixed, long", testExec(t, fl, "", "foo --one --two bar", "", "true;bar")) + t.Run("mixed, long, explicit", testExec(t, fl, "", "foo --one true --two bar", "", "true;bar")) + }) + + t.Run("implicit bool option", func(t *testing.T) { + t.Run("short", testExec(t, ShortForm(fb, "one", "a"), "", "foo -a", "", "true;false;false;false")) + t.Run( + "short, multiple", + testExec(t, ShortForm(fb, "one", "a", "two", "b", "three", "c"), "", "foo -a -b -c", "", "true;true;true;false"), + ) + + t.Run( + "short, combined", + testExec(t, ShortForm(fb, "one", "a", "two", "b", "three", "c"), "", "foo -abc", "", "true;true;true;false"), + ) + + t.Run( + "short, combined, multiple", + testExec(t, ShortForm(fb, "one", "a", "two", "b", "three", "c", "four", "d"), "", "foo -ab -cd", "", "true;true;true;true"), + ) + + t.Run( + "short, multiple values", + testExec(t, ShortForm(flb, "one", "a", "two", "b", "three", "c"), "", "foo -aba -cab", "", "true,true,true;true,true;true"), + ) + + t.Run("long", testExec(t, fb, "", "foo --one", "", "true;false;false;false")) + t.Run("long, multiple", testExec(t, fb, "", "foo --one --two --three", "", "true;true;true;false")) + t.Run("long, multiple values", testExec(t, flb, "", "foo --one --two --one", "", "true,true;true;")) + }) + + t.Run("explicit bool option", func(t *testing.T) { + t.Run("short, true", testExec(t, ShortForm(fb, "one", "a"), "", "foo -a true", "", "true;false;false;false")) + t.Run("short, false", testExec(t, ShortForm(fb, "one", "a"), "", "foo -a false", "", "false;false;false;false")) + t.Run("short, with eq", testExec(t, ShortForm(fb, "one", "a"), "", "foo -a=true", "", "true;false;false;false")) + t.Run("short, true variant, capital", testExec(t, ShortForm(fb, "one", "a"), "", "foo -a True", "", "true;false;false;false")) + t.Run("short, true variant, 1", testExec(t, ShortForm(fb, "one", "a"), "", "foo -a 1", "", "true;false;false;false")) + t.Run("short, false variant, 0", testExec(t, ShortForm(fb, "one", "a"), "", "foo -a 0", "", "false;false;false;false")) + t.Run("short, combined", testExec(t, ShortForm(fb, "one", "a", "two", "b"), "", "foo -ab true", "", "true;true;false;false")) + t.Run( + "short, combined, multiple", + testExec( + t, + ShortForm(fb, "one", "a", "two", "b", "three", "c", "four", "d"), + "", "foo -ab true -cd true", + "", "true;true;true;true", + ), + ) + + t.Run("long", testExec(t, fb, "", "foo --one true", "", "true;false;false;false")) + t.Run("long, false", testExec(t, fb, "", "foo --one false", "", "false;false;false;false")) + t.Run("logn, with eq", testExec(t, fb, "", "foo --one=true", "", "true;false;false;false")) + t.Run("long, mixed, first", testExec(t, fb, "", "foo --one false --two", "", "false;true;false;false")) + t.Run("long, mixed, last", testExec(t, fb, "", "foo --one --two false", "", "true;false;false;false")) + }) + + t.Run("expected bool option", func(t *testing.T) { + t.Run("short, implicit", testExec(t, ShortForm(fb, "one", "a"), "", "foo -a", "", "true;false;false;false")) + t.Run("short, explicit", testExec(t, ShortForm(fb, "one", "a"), "", "foo -a true", "", "true;false;false;false")) + t.Run("short, automatic positional", testExec(t, ShortForm(fbp, "one", "a"), "", "foo -a bar", "", "true;false;false;false;bar")) + t.Run("short, combined", testExec(t, ShortForm(fb, "one", "a", "two", "b"), "", "foo -ab true", "", "true;true;false;false")) + t.Run( + "short, combined, automatic positional", + testExec(t, ShortForm(fbp, "one", "a", "two", "b"), "", "foo -ab bar", "", "true;true;false;false;bar"), + ) + + t.Run("long, implicit", testExec(t, fb, "", "foo --one", "", "true;false;false;false")) + t.Run("long, explicit", testExec(t, fb, "", "foo --one true", "", "true;false;false;false")) + t.Run("long, automatic positional", testExec(t, fbp, "", "foo --one bar", "", "true;false;false;false;bar")) + }) + + t.Run("positional", func(t *testing.T) { + t.Run("basic", testExec(t, fp, "", "foo bar baz", "", "0;bar,baz")) + t.Run("explicit", testExec(t, fp, "", "foo -- bar baz", "", "0;bar,baz")) + t.Run("mixed", testExec(t, fp, "", "foo bar -- baz", "", "0;bar,baz")) + t.Run("with option", testExec(t, fp, "", "foo bar --second-field 42 baz", "", "42;bar,baz")) + t.Run("with bool option at the end", testExec(t, fbp, "", "foo bar baz --one", "", "true;false;false;false;bar;baz")) + t.Run("with expected bool, implicit", testExec(t, fbp, "", "foo bar --one baz", "", "true;false;false;false;bar;baz")) + t.Run("with expected bool, explicit", testExec(t, fbp, "", "foo bar --one true baz", "", "true;false;false;false;bar;baz")) + t.Run("option format", testExec(t, fbp, "", "foo -- --one", "", "false;false;false;false;--one")) + }) + + t.Run("example", func(t *testing.T) { + type s struct { + Foo bool + Bar []bool + Qux bool + Quux string + } + + fs := func(s s, a1, a2 string) string { + var sbar []string + for _, b := range s.Bar { + sbar = append(sbar, fmt.Sprint(b)) + } + + return fmt.Sprintf("%t;%s;%t;%s;%s;%s", s.Foo, strings.Join(sbar, ","), s.Qux, s.Quux, a1, a2) + } + + t.Run( + "full", + testExec( + t, + ShortForm(fs, "foo", "a", "bar", "b"), + "", + "foo -ab --bar baz -b --qux --quux corge -- grault", + "", + "true;true,true,true;true;corge;baz;grault", + ), + ) + }) + + t.Run("expected or unexpected", func(t *testing.T) { + t.Run("capital letters", testExec(t, fp, "", "foo --One bar", "", "0;--One,bar")) + t.Run("digit in option name", testExec(t, fd, "", "foo --one-2", "", "true")) + t.Run("dash in option name", testExec(t, ff, "", "foo --second-field 42", "", "42")) + t.Run("unpexpected character", testExec(t, fp, "", "foo --one#", "", "0;--one#")) + t.Run( + "invalid short option set", + testExec(t, ShortForm(fp, "one", "a", "one", "b", "second-field", "c"), "", "foo -aBc", "", "0;-aBc"), + ) + + t.Run("positional separator, no value", testExec(t, fp, "", "foo --one bar --", "", "bar0;")) + t.Run("positional separator, expecting value", testExec(t, fp, "", "foo --one --", "--one", "")) + t.Run("shot flag set, expecting value", testExec(t, ShortForm(fp, "second-field", "b"), "", "foo --one -b", "--one", "")) + }) + + t.Run("preserve order", func(t *testing.T) { + t.Run("bools", testExec(t, fl, "", "foo --one --one false --one", "", "true,false,true;")) + t.Run("strings", testExec(t, fl, "", "foo --two 1 --two 2 --two 3", "", ";1,2,3")) + }) + + t.Run("select subcommand", func(t *testing.T) { + t.Run("named", testExec(t, Command("", nil, Command("bar", ff), Command("baz", ff)), "", "foo baz", "", "0")) + t.Run("default", testExec(t, Command("", nil, Command("bar", ff), Default(Command("baz", ff))), "", "foo", "", "0")) + }) +} diff --git a/env.go b/env.go new file mode 100644 index 0000000..a7e9138 --- /dev/null +++ b/env.go @@ -0,0 +1,70 @@ +package wand + +import ( + "github.com/iancoleman/strcase" + "strings" +) + +type env struct { + values map[string][]string + originalNames map[string]string +} + +func splitEnvValue(v string) []string { + var ( + values []string + escape bool + current []rune + ) + + for _, r := range []rune(v) { + if escape { + current = append(current, r) + escape = false + continue + } + + if r == '\\' { + escape = true + continue + } + + if r == ':' { + values = append(values, string(current)) + current = nil + continue + } + + current = append(current, r) + } + + values = append(values, string(current)) + return values +} + +func readEnv(appName string, input []string) env { + appName = strcase.ToKebab(appName) + e := env{ + values: make(map[string][]string), + originalNames: make(map[string]string), + } + + for _, i := range input { + parts := strings.SplitN(i, "=", 2) + if len(parts) != 2 { + continue + } + + key, value := parts[0], parts[1] + key = strcase.ToKebab(key) + if len(key) <= len(appName)+1 || !strings.HasPrefix(key, appName+"-") { + continue + } + + key = key[len(appName)+1:] + e.originalNames[key] = parts[0] + e.values[key] = splitEnvValue(value) + } + + return e +} diff --git a/env_test.go b/env_test.go new file mode 100644 index 0000000..de50f9d --- /dev/null +++ b/env_test.go @@ -0,0 +1,42 @@ +package wand + +import ( + "fmt" + "strings" + "testing" +) + +func TestEnv(t *testing.T) { + type f struct{ One, SecondVar string } + ff := func(f f) string { + return f.One + f.SecondVar + } + + type i struct{ One, SecondVar int } + fi := func(i i) string { + return fmt.Sprintf("%d;%d", i.One, i.SecondVar) + } + + type m struct{ One, SecondVar []string } + fm := func(m m) string { + return strings.Join([]string{strings.Join(m.One, ","), strings.Join(m.SecondVar, ",")}, ";") + } + + t.Run("none match app prefix", testExec(t, ff, "SOME_VAR=foo;SOME_OTHER=bar", "baz", "", "")) + t.Run("common environment var casing", testExec(t, ff, "FOO_ONE=bar;FOO_SECOND_VAR=baz", "foo", "", "barbaz")) + t.Run("camel casing", testExec(t, ff, "fooOne=bar;fooSecondVar=baz", "foo", "", "barbaz")) + t.Run("empty env var", testExec(t, ff, "fooOne=bar;fooSecondVar=", "foo", "", "bar")) + t.Run("multipart app name", testExec(t, ff, "fooBarOne=baz;FOO_BAR_SECOND_VAR=qux", "foo-bar", "", "bazqux")) + t.Run("invalid env var", testExec(t, ff, "fooOne=bar;fooSecondVar=baz;fooQux", "foo", "", "barbaz")) + t.Run("eq in value", testExec(t, ff, "fooOne=bar=baz", "foo", "", "bar=baz")) + t.Run("keeps original name", testExec(t, fi, "FOO_ONE=bar", "foo", "FOO_ONE", "")) + t.Run("keeps original name, last wins on conflict", testExec(t, fi, "FOO_ONE=bar;fooOne=baz", "foo", "fooOne", "")) + t.Run("multiple values", func(t *testing.T) { + t.Run("2", testExec(t, fm, "fooOne=bar:baz", "foo", "", "bar,baz;")) + t.Run("3", testExec(t, fm, "fooOne=bar:baz:qux", "foo", "", "bar,baz,qux;")) + t.Run("with empty", testExec(t, fm, "fooOne=bar:baz::qux:", "foo", "", "bar,baz,,qux,;")) + t.Run("escape", testExec(t, fm, "fooOne=bar\\:baz", "foo", "", "bar:baz;")) + t.Run("escape char", testExec(t, fm, "fooOne=bar\\\\:baz", "foo", "", "bar\\,baz;")) + t.Run("escape char last", testExec(t, fm, "fooOne=bar\\", "foo", "", "bar;")) + }) +} diff --git a/exec.go b/exec.go new file mode 100644 index 0000000..b881767 --- /dev/null +++ b/exec.go @@ -0,0 +1,60 @@ +package wand + +import ( + "errors" + "fmt" + "github.com/iancoleman/strcase" + "io" + "path/filepath" +) + +func exec(stdout, stderr io.Writer, exit func(int), cmd Cmd, env, args []string) { + cmd = insertHelp(cmd) + _, cmd.name = filepath.Split(args[0]) + cmd.name = strcase.ToKebab(cmd.name) + if err := validateCommand(cmd); err != nil { + panic(err) + } + + args = args[1:] + e := readEnv(cmd.name, env) + cmd, fullCmd, args := selectCommand(cmd, args) + if cmd.impl == nil { + fmt.Fprint(stderr, errors.New("subcommand not specified")) + suggestHelp(stderr, cmd, fullCmd) + exit(1) + return + } + + if cmd.helpRequested { + showHelp(stdout, cmd, fullCmd) + return + } + + bo := boolOptions(cmd) + cl := readArgs(bo, args) + if hasHelpOption(cmd, cl.options) { + showHelp(stdout, cmd, fullCmd) + return + } + + if err := validateInput(cmd, e, cl); err != nil { + fmt.Fprint(stderr, err) + suggestHelp(stderr, cmd, fullCmd) + exit(1) + return + } + + output, err := apply(cmd, e, cl) + if err != nil { + fmt.Fprint(stderr, err) + exit(1) + return + } + + if err := printOutput(stdout, output); err != nil { + fmt.Fprint(stderr, err) + exit(1) + return + } +} diff --git a/exec_test.go b/exec_test.go new file mode 100644 index 0000000..3e0d25a --- /dev/null +++ b/exec_test.go @@ -0,0 +1,45 @@ +package wand + +import ( + "bytes" + "fmt" + "strings" + "testing" +) + +func testExec(impl any, env, commandLine, err string, expect ...string) func(*testing.T) { + return func(t *testing.T) { + var exitCode int + exit := func(code int) { exitCode = code } + stdout := bytes.NewBuffer(nil) + stderr := bytes.NewBuffer(nil) + cmd := wrap(impl) + e := strings.Split(env, ";") + a := strings.Split(commandLine, " ") + exec(stdout, stderr, exit, cmd, e, a) + if exitCode != 0 && err == "" { + t.Fatal("non-zero exit code:", stderr.String()) + } + + if err != "" && exitCode == 0 { + t.Fatal("failed to fail") + } + + if err != "" && !strings.Contains(stderr.String(), err) { + t.Fatal("expected error not received:", stderr.String()) + } + + if exitCode != 0 { + return + } + + var expstr []string + for _, e := range expect { + expstr = append(expstr, fmt.Sprint(e)) + } + + if stdout.String() != strings.Join(expstr, "\n")+"\n" { + t.Fatal("unexpected output:", stdout.String()) + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cb0ff3b --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module code.squareroundforest.org/arpio/wand + +go 1.24.2 + +require ( + code.squareroundforest.org/arpio/notation v0.0.0-20241225183158-af3bd591a174 // indirect + github.com/iancoleman/strcase v0.3.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..38e8be7 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +code.squareroundforest.org/arpio/notation v0.0.0-20241225183158-af3bd591a174 h1:DKMSagVY3uyRhJ4ohiwQzNnR6CWdVKLkg97A8eQGxQU= +code.squareroundforest.org/arpio/notation v0.0.0-20241225183158-af3bd591a174/go.mod h1:ait4Fvg9o0+bq5hlxi9dAcPL5a+/sr33qsZPNpToMLY= +github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= +github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= diff --git a/help.go b/help.go new file mode 100644 index 0000000..6954663 --- /dev/null +++ b/help.go @@ -0,0 +1,81 @@ +package wand + +import ( + "fmt" + "io" + "strings" +) + +type ( + synopsis struct{} + docOptions struct{} + docArguments struct{} + docSubcommands struct{} +) + +type doc struct { + name string + synopsis synopsis + description string + options docOptions + arguments docArguments + subcommands docSubcommands +} + +func help() Cmd { + return Cmd{ + name: "help", + isHelp: true, + } +} + +func insertHelp(cmd Cmd) Cmd { + var hasHelpCmd bool + for i, sc := range cmd.subcommands { + cmd.subcommands[i] = insertHelp(sc) + if cmd.name == "help" { + hasHelpCmd = true + } + } + + if !hasHelpCmd { + cmd.subcommands = append(cmd.subcommands, help()) + } + + return cmd +} + +func hasHelpSubcommand(cmd Cmd) bool { + for _, sc := range cmd.subcommands { + if sc.isHelp { + return true + } + } + + return false +} + +func hasCustomHelpOption(cmd Cmd) bool { + mf := mapFields(cmd.impl) + _, has := mf["help"] + return has +} + +func suggestHelp(out io.Writer, cmd Cmd, fullCommand []string) { + if hasHelpSubcommand(cmd) { + fmt.Fprintf(out, "Show help:\n%s help\n", strings.Join(fullCommand, " ")) + return + } + + if !hasCustomHelpOption(cmd) { + fmt.Fprintf(out, "Show help:\n%s --help\n", strings.Join(fullCommand, " ")) + return + } +} + +func constructDoc(cmd Cmd, fullCommand []string) doc { + return doc{} +} + +func showHelp(out io.Writer, cmd Cmd, fullCommand []string) { +} diff --git a/input.go b/input.go new file mode 100644 index 0000000..47071e9 --- /dev/null +++ b/input.go @@ -0,0 +1,166 @@ +package wand + +import ( + "fmt" + "reflect" + "slices" +) + +func validateEnv(cmd Cmd, e env) error { + mf := mapFields(cmd.impl) + for name, values := range e.values { + f, ok := mf[name] + if !ok { + continue + } + + for _, fi := range f { + if len(values) > 1 && !fi.acceptsMultiple { + return fmt.Errorf( + "expected only one value, received %d, as environment value, %s", + len(values), + e.originalNames[name], + ) + } + + for _, v := range values { + if !canScan(fi.typ, v) { + return fmt.Errorf( + "environment variable cannot be applied, type mismatch: %s", + e.originalNames[name], + ) + } + } + } + } + + return nil +} + +func validateOptions(cmd Cmd, o []option) error { + ml := make(map[string]string) + ms := make(map[string]string) + for i := 0; i < len(cmd.shortForms); i += 2 { + l, s := cmd.shortForms[i], cmd.shortForms[i+1] + ml[l] = s + ms[s] = l + } + + mo := make(map[string][]option) + for _, oi := range o { + n := oi.name + if ln, ok := ms[n]; ok && oi.shortForm { + n = ln + } + + mo[n] = append(mo[n], oi) + } + + mf := mapFields(cmd.impl) + for n, os := range mo { + f := mf[n] + for _, fi := range f { + en := "--" + n + if sn, ok := ml[n]; ok { + en += ", -" + sn + } + + if len(os) > 1 && !fi.acceptsMultiple { + return fmt.Errorf( + "expected only one value, received %d, as option, %s", + len(os), + en, + ) + } + + for _, oi := range os { + if oi.value.isBool && fi.typ.Kind() != reflect.Bool { + return fmt.Errorf( + "received boolean value for field that does not accept it: %s", + en, + ) + } + + if !oi.value.isBool && !canScan(fi.typ, oi.value.str) { + return fmt.Errorf( + "option cannot be applied, type mismatch: %s", + en, + ) + } + } + } + } + + return nil +} + +func validatePositionalArgs(cmd Cmd, a []string) error { + v := reflect.ValueOf(cmd.impl) + v = unpack(v) + t := v.Type() + p := positionalParameters(t) + ior, iow := ioParameters(p) + last := t.NumIn()-1 + lastVariadic := t.IsVariadic() && + !isStruct(t.In(last)) && + !slices.Contains(ior, last) && + !slices.Contains(iow, last) + length := len(p) - len(ior) - len(iow) + min := length + max := length + if lastVariadic { + min-- + max = -1 + } + + if cmd.minPositional > min { + min = cmd.minPositional + } + + if cmd.maxPositional > 0 { + max = cmd.maxPositional + } + + if len(a) < min { + return fmt.Errorf("not enough positional arguments, expected minimum %d", min) + } + + if max >= 0 && len(a) > max { + return fmt.Errorf("too many positional arguments, expected maximum %d", max) + } + + for i, ai := range a { + var pi reflect.Type + if i >= length { + pi = p[length-1] + } else { + pi = p[i] + } + + if !canScan(pi, ai) { + return fmt.Errorf( + "cannot apply positional argument at index %d, expecting %v", + i, + pi, + ) + } + } + + return nil +} + +func validateInput(cmd Cmd, e env, cl commandLine) error { + if err := validateEnv(cmd, e); err != nil { + return err + } + + if err := validateOptions(cmd, cl.options); err != nil { + return err + } + + if err := validatePositionalArgs(cmd, cl.positional); err != nil { + return err + } + + return nil +} diff --git a/notes.txt b/notes.txt new file mode 100644 index 0000000..3f22f14 --- /dev/null +++ b/notes.txt @@ -0,0 +1,4 @@ +io.Writer arg: pass in os.Stdout +io.Reader arg: pass in os.Stdin +test: method docs +during validation, reject circular type references diff --git a/output.go b/output.go new file mode 100644 index 0000000..5ee8825 --- /dev/null +++ b/output.go @@ -0,0 +1,25 @@ +package wand + +import ( + "fmt" + "io" +) + +func printOutput(w io.Writer, o []any) error { + for _, oi := range o { + r, ok := oi.(io.Reader) + if ok { + if _, err := io.Copy(w, r); err != nil { + return fmt.Errorf("error copying output: %w", err) + } + + continue + } + + if _, err := fmt.Fprintf(w, "%v\n", oi); err != nil { + return fmt.Errorf("error printing output: %w", err) + } + } + + return nil +} diff --git a/reflect.go b/reflect.go new file mode 100644 index 0000000..97adb71 --- /dev/null +++ b/reflect.go @@ -0,0 +1,298 @@ +package wand + +import ( + "github.com/iancoleman/strcase" + "reflect" + "strconv" + "io" +) + +type packedKind[T any] interface { + Kind() reflect.Kind + Elem() T +} + +type field struct { + name string + typ reflect.Type + acceptsMultiple bool +} + +var ( + readerType = reflect.TypeFor[io.Reader]() + writerType = reflect.TypeFor[io.Writer]() +) + +func pack(v reflect.Value, t reflect.Type) reflect.Value { + if v.Type() == t { + return v + } + + if t.Kind() == reflect.Pointer { + pv := pack(v, t.Elem()) + p := reflect.New(t.Elem()) + p.Elem().Set(pv) + return p + } + + iv := pack(v, t.Elem()) + s := reflect.MakeSlice(t, 1, 1) + s.Index(0).Set(iv) + return s +} + +func unpack[T packedKind[T]](p T) T { + switch p.Kind() { + case reflect.Pointer, + reflect.Slice: + return unpack(p.Elem()) + default: + return p + } +} + +func isReader(t reflect.Type) bool { + return unpack(t) == readerType +} + +func isWriter(t reflect.Type) bool { + return unpack(t) == writerType +} + +func isStruct(t reflect.Type) bool { + t = unpack(t) + return t.Kind() == reflect.Struct +} + +func canScan(t reflect.Type, s string) bool { + switch t.Kind() { + case reflect.Bool: + _, err := strconv.ParseBool(s) + return err == nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + _, err := strconv.ParseInt(s, 10, int(t.Size())*8) + return err == nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + _, err := strconv.ParseUint(s, 10, int(t.Size())*8) + return err == nil + case reflect.Float32, reflect.Float64: + _, err := strconv.ParseFloat(s, int(t.Size())*8) + return err == nil + case reflect.String: + return true + default: + return false + } +} + +func scan(t reflect.Type, s string) any { + p := reflect.New(t) + switch t.Kind() { + case reflect.Bool: + v, _ := strconv.ParseBool(s) + p.Elem().Set(reflect.ValueOf(v).Convert(t)) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + v, _ := strconv.ParseInt(s, 10, int(t.Size())*8) + p.Elem().Set(reflect.ValueOf(v).Convert(t)) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + v, _ := strconv.ParseUint(s, 10, int(t.Size())*8) + p.Elem().Set(reflect.ValueOf(v).Convert(t)) + case reflect.Float32, reflect.Float64: + v, _ := strconv.ParseFloat(s, int(t.Size())*8) + p.Elem().Set(reflect.ValueOf(v).Convert(t)) + default: + p.Elem().Set(reflect.ValueOf(s).Convert(t)) + } + + return p.Elem().Interface() +} + +func fields(s ...reflect.Type) []field { + if len(s) == 0 { + return nil + } + + var ( + anonFields []field + plainFields []field + ) + + for i := 0; i < s[0].NumField(); i++ { + sf := s[0].Field(i) + sft := sf.Type + am := acceptsMultiple(sft) + sft = unpack(sft) + sfn := sf.Name + sfn = strcase.ToKebab(sfn) + switch sft.Kind() { + case reflect.Bool, + reflect.Int, + reflect.Int8, + reflect.Int16, + reflect.Int32, + reflect.Int64, + reflect.Uint, + reflect.Uint8, + reflect.Uint16, + reflect.Uint32, + reflect.Uint64, + reflect.Float32, + reflect.Float64, + reflect.String: + plainFields = append(plainFields, field{name: sfn, typ: sft, acceptsMultiple: am}) + case reflect.Interface: + if sft.NumMethod() == 0 { + plainFields = append(plainFields, field{name: sfn, typ: sft, acceptsMultiple: am}) + } + case reflect.Struct: + sff := fields(sft) + if sf.Anonymous { + anonFields = append(anonFields, sff...) + } else { + for i := range sff { + sff[i].name = sfn + "-" + sff[i].name + sff[i].acceptsMultiple = sff[i].acceptsMultiple || am + } + + plainFields = append(plainFields, sff...) + } + } + } + + mf := make(map[string]field) + for _, fi := range anonFields { + mf[fi.name] = fi + } + + for _, fi := range plainFields { + mf[fi.name] = fi + } + + var f []field + for _, fi := range mf { + f = append(f, fi) + } + + return append(f, fields(s[1:]...)...) +} + +func boolFields(f []field) []field { + var b []field + for _, fi := range f { + if fi.typ.Kind() == reflect.Bool { + b = append(b, fi) + } + } + + return b +} + +func mapFields(impl any) map[string][]field { + v := reflect.ValueOf(impl) + t := v.Type() + t = unpack(t) + s := structParameters(t) + f := fields(s...) + mf := make(map[string][]field) + for _, fi := range f { + mf[fi.name] = append(mf[fi.name], fi) + } + + return mf +} + +func filterParameters(t reflect.Type, f func(reflect.Type) bool) []reflect.Type { + var s []reflect.Type + for i := 0; i < t.NumIn(); i++ { + p := t.In(i) + p = unpack(p) + if f(p) { + s = append(s, p) + } + } + + return s +} + +func positionalParameters(t reflect.Type) []reflect.Type { + return filterParameters(t, func(p reflect.Type) bool { + return p.Kind() != reflect.Struct + }) +} + +func ioParameters(p []reflect.Type) ([]int, []int) { + var ( + reader []int + writer []int + ) + + for i, pi := range p { + switch { + case isReader(pi): + reader = append(reader, i) + case isWriter(pi): + writer = append(writer, i) + } + } + + return reader, writer +} + +func structParameters(t reflect.Type) []reflect.Type { + return filterParameters(t, func(p reflect.Type) bool { + return p.Kind() == reflect.Struct + }) +} + +func compatibleTypes(t ...reflect.Type) bool { + if len(t) < 2 { + return true + } + + t0 := t[0] + t1 := t[1] + switch t0.Kind() { + case reflect.Bool: + return t1.Kind() == reflect.Bool + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + switch t1.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return true + default: + return false + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + switch t1.Kind() { + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return true + default: + return false + } + case reflect.Float32, reflect.Float64: + switch t1.Kind() { + case reflect.Float32, reflect.Float64: + return true + default: + return false + } + case reflect.String: + return t1.Kind() == reflect.String + case reflect.Interface: + return t1.Kind() == reflect.Interface && t0.NumMethod() == 0 && t1.NumMethod() == 0 + default: + return false + } +} + +func acceptsMultiple(t reflect.Type) bool { + if t.Kind() == reflect.Slice { + return true + } + + switch t.Kind() { + case reflect.Pointer, reflect.Slice: + return acceptsMultiple(t.Elem()) + default: + return false + } +} diff --git a/wand.go b/wand.go new file mode 100644 index 0000000..1188b86 --- /dev/null +++ b/wand.go @@ -0,0 +1,42 @@ +package wand + +import "os" + +type Cmd struct { + name string + impl any + subcommands []Cmd + isDefault bool + minPositional int + maxPositional int + shortForms []string + description string + isHelp bool + helpRequested bool +} + +func Command(name string, impl any, subcmds ...Cmd) Cmd { + return command(name, impl, subcmds...) +} + +func Default(cmd Cmd) Cmd { + cmd.isDefault = true + return cmd +} + +// io doesn't count +func Args(cmd Cmd, min, max int) Cmd { + cmd.minPositional = min + cmd.maxPositional = max + return cmd +} + +func ShortForm(cmd Cmd, f ...string) Cmd { + cmd.shortForms = f + return cmd +} + +func Exec(impl any) { + cmd := wrap(impl) + exec(os.Stdout, os.Stderr, os.Exit, cmd, os.Environ(), os.Args) +} diff --git a/wand_test.go b/wand_test.go new file mode 100644 index 0000000..4ef36cf --- /dev/null +++ b/wand_test.go @@ -0,0 +1 @@ +package wand