commit 0131d6d95584242fbe871aca257620328d37640c Author: Arpad Ryszka Date: Mon Aug 18 14:24:31 2025 +0200 init repo 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