-
Notifications
You must be signed in to change notification settings - Fork 6
/
Copy pathsharper.el
1399 lines (1260 loc) · 63.6 KB
/
sharper.el
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
;;; sharper.el --- A dotnet CLI wrapper, using Transient -*- lexical-binding: t; -*-
;; Copyright (C) 2020 Sebastian Monia
;;
;; Author: Sebastian Monia <[email protected]>
;; URL: https://github.com/sebasmonia/sharper
;; Package-Requires: ((emacs "27.1") (transient "0.2.0"))
;; Version: 1.0
;; Keywords: maint tool
;; This file is not part of GNU Emacs.
;;; SPDX-License-Identifier: MIT
;;; Commentary:
;; This aims to be a complete package for dotnet tasks that aren't part
;; of the languages but needed for any project: Solution management, nuget, etc.
;;
;; Steps to setup:
;; 1. Place sharper.el in your load-path. Or install from MELPA.
;; 2. Add a binding to start sharper's transient:
;; (require 'sharper)
;; (global-set-key (kbd "C-c n") 'sharper-main-transient) ;; For "n" for "dot NET"
;;
;; Some commands show lists of items. In those cases, "RET" shows the transient with the Actions
;; available.
;;
;; For a detailed user manual see:
;; https://github.com/sebasmonia/sharper/blob/master/README.md
;;; Code:
(require 'transient)
(require 'cl-lib)
(require 'cl-extra) ;; for cl-some
(require 'json)
(require 'thingatpt)
(require 'project)
(require 'url-http)
;; This var is defined in url-http, added here to silence compiler warnings,
;; see https://github.com/melpa/melpa/pull/7141 for more details
(defvar url-http-end-of-headers)
;;------------------Customization options-----------------------------------------
(defgroup sharper nil
"dotnet CLI wrapper, using Transient."
:group 'extensions)
(defcustom sharper-project-extensions '("csproj" "fsproj")
"Valid extensions for project files."
:type '(repeat string))
(defcustom sharper-RIDs-URL "https://raw.githubusercontent.com/dotnet/runtime/master/src/libraries/pkg/Microsoft.NETCore.Platforms/runtime.json"
"URL to fetch the list of Runtime Identifiers for dotnet. See https://docs.microsoft.com/en-us/dotnet/core/rid-catalog for more info."
:type 'string)
(defcustom sharper-nuget-search-URL "https://azuresearch-usnc.nuget.org/query?q=%s&prerelease=true&semVerLevel=2.0.0&take=250"
"URL to run a NuGet search. Must contain a %s to replace with the search string the user will input."
:type 'string)
(defcustom sharper-run-only-one nil
"When calling \"dotnet run\", don't allow more than a single process per project."
:type 'boolean)
;; Legend for the templates below:
;; %t = TARGET
;; %o = OPTIONS
;; %s = SOLUTION
;; %m = MS BUILD PROPERTIES (-p:Prop1=Val1 -p:Prop2=Val2 )
;; %a = RUN SETTINGS (dotnet test) or APPLICATION ARGUMENTS (dotnet run)
;; %p = PROJECT (when project != target)
;; %k = PACKAGE NAME
;; %e = TEST NAME
(defvar sharper--build-template "dotnet build %t %o %m" "Template for \"dotnet build\" invocations.")
(defvar sharper--test-template "dotnet test %t %o %a" "Template for \"dotnet test\" invocations.")
(defvar sharper--watch-test-template "dotnet watch test %t %o %a" "Template for \"dotnet watch test\" invocations.")
(defvar sharper--test-methodfunc-template "dotnet test --filter %e" "Template for \"dotnet test\" invocations to run the \"current test\".")
(defvar sharper--clean-template "dotnet clean %t %o" "Template for \"dotnet clean\" invocations.")
(defvar sharper--publish-template "dotnet publish %t %o" "Template for \"dotnet publish\" invocations.")
(defvar sharper--pack-template "dotnet pack %t %o %m" "Template for \"dotnet pack\" invocations.")
(defvar sharper--run-template "dotnet run --project %t %o %a" "Template for \"dotnet run\" invocations.")
(defvar sharper--watch-run-template "dotnet watch run --project %t %o %a" "Template for \"dotnet watch run\" invocations.")
(defvar sharper--sln-list-template "dotnet sln %t list" "Template for \"dotnet sln list\" invocations.")
(defvar sharper--sln-add-template "dotnet sln %t add %p" "Template for \"dotnet sln add\" invocations.")
(defvar sharper--sln-remove-template "dotnet sln %t remove %p" "Template for \"dotnet sln remove\" invocations.")
(defvar sharper--reference-list-template "dotnet list %t reference" "Template for \"dotnet list reference\" invocations.")
(defvar sharper--reference-add-template "dotnet add %t reference %p" "Template for \"dotnet add reference\" invocations.")
(defvar sharper--reference-remove-template "dotnet remove %t reference %p" "Template for \"dotnet remove reference\" invocations.")
(defvar sharper--package-list-template "dotnet list %t package" "Template for \"dotnet list package\" invocations.")
(defvar sharper--package-list-transitive-template "dotnet list %t package --include-transitive" "Template for \"dotnet list package\" including transitive packages.")
(defvar sharper--package-add-template "dotnet add %t package %k %o" "Template for \"dotnet add package\" invocations.")
(defvar sharper--package-remove-template "dotnet remove %t package %k" "Template for \"dotnet remove package\" invocations.")
;; NOTE: if I start needing more than just default-dir + command I might as well create
;; a struct that has directory, command, prop1, prop2 etc.
(defvar sharper--last-build nil "A cons cell (directory . last command used for a build).")
(defvar sharper--last-test nil "A cons cell (directory . last command used to run tests).")
(defvar sharper--last-publish nil "A cons cell (directory . last command used to run a publish).")
(defvar sharper--last-pack nil "A cons cell (directory . last command used to create a NuGet package).")
(defvar sharper--last-run nil "A three item list (directory last . command used for \"dotnet run\" . project name).")
(defvar sharper--current-test nil "A cons cell (directory . test-name) used when running tests using the method/function at point as parameter.")
(defvar sharper--current-build nil "A string with the directory used when building the nearest project.")
(defvar sharper--cached-RIDs nil "The list of Runtime IDs used for completion.")
(defvar-local sharper--solution-path nil "Used in `sharper--solution-management-mode' to store the current solution.")
(defvar-local sharper--project-path nil "Used in `sharper--project-references-mode' and `sharper--project-packages-mode' to store the current project.")
;;------------------Package infrastructure----------------------------------------
(defun sharper--message (text)
"Show a TEXT as a message and log it, if 'panda-less-messages' log only."
(message "Sharper: %s" text)
(sharper--log "Package message:\n" text "\n"))
(defun sharper--log-command (title command)
"Log a COMMAND, using TITLE as header, using the predefined format."
(sharper--log
(concat "[command] " title
"\n " command
"\n in directory: " default-directory
"\n")))
(defun sharper--log (&rest to-log)
"Append TO-LOG to the log buffer. Intended for internal use only."
(let ((log-buffer (get-buffer-create "*sharper-log*"))
(text (cl-reduce (lambda (accum elem) (concat accum " " (prin1-to-string elem t))) to-log)))
(with-current-buffer log-buffer
(goto-char (point-max))
(insert text)
(insert "\n"))))
(defun sharper--json-request (url &optional params method data)
"Retrieve JSON result of calling URL with PARAMS and DATA using METHOD (default GET). Return parsed objects."
;; Based on the Panda function for API calls to Bamboo
(unless data
(setq data ""))
(let ((url-request-extra-headers
`(("Accept" . "application/json")
("Content-Type" . "application/json")))
(url-request-method (or method "GET"))
(url-request-data (encode-coding-string data 'utf-8))
(json-false :false))
(when params
(setq url (concat url "&" params)))
(sharper--log "Requesting JSON data: " url-request-method "to " url " with data " url-request-data)
(sharper--message "Web request...")
;; 10 seconds timeout and silenced URL module included
(with-current-buffer (url-retrieve-synchronously url t nil 10)
(set-buffer-multibyte t)
(goto-char url-http-end-of-headers)
(let ((parsed-json 'error))
(ignore-errors
;; if there's a problem parsing the JSON
;; parsed-json ==> 'error
(if (fboundp 'json-parse-buffer)
(setq parsed-json (json-parse-buffer
:object-type 'alist))
;; Legacy :)
(setq parsed-json (json-read))))
(kill-buffer) ;; don't litter with API buffers
(message nil) ;; clear echo area
parsed-json))))
(defun sharper--set-static-output-buffer (buffer-or-name)
"Set BUFFER-OR-NAME as read-only and add a local \"q\" binding to kill it.
Return the buffer."
(with-current-buffer buffer-or-name
(setq buffer-read-only t)
(local-set-key "q" (lambda ()
(interactive)
(kill-buffer)))
(get-buffer buffer-or-name)))
;;------------------Main transient------------------------------------------------
(transient-define-prefix sharper-main-transient ()
"dotnet Menu"
["Build"
("B" "new build" sharper-transient-build)
("b" (lambda () (sharper--repeat-description sharper--last-build)) sharper--run-last-build)
("sb" (lambda () (sharper--build-nearest-setup)) sharper--run-nearest-build)]
["Run test"
("T" "new test run" sharper-transient-test)
("t" (lambda () (sharper--repeat-description sharper--last-test)) sharper--run-last-test)
("st" (lambda () (sharper--test-point-setup)) sharper--run-test-at-point)]
["Run application"
("R" "new application run" sharper-transient-run)
;; As an exception, last run as a different structure than the other commands
("r" (lambda () (sharper--repeat-description-run sharper--last-run)) sharper--run-last-run)]
["Publish app"
("P" "new publish" sharper-transient-publish)
("p" (lambda () (sharper--repeat-description sharper--last-publish)) sharper--run-last-publish)]
["Generating a nuget package file"
("N" "new package" sharper-transient-pack)
("n" (lambda () (sharper--repeat-description sharper--last-pack)) sharper--run-last-pack)
;;("wp" "wizard for package metadata" sharper-transient-???)
]
["Solution & project management"
("ms" "Manage solution" sharper--manage-solution)
("mr" "Manage project references" sharper--manage-project-references)
("mp" "Manage project packages" sharper--manage-project-packages)]
["Misc commands"
("c" "clean" sharper-transient-clean)
("v" "version info (SDK & runtimes)" sharper--version-info)
("q" "quit" transient-quit-all)])
;; TODO: commands I haven't used and could (should?) be implemented:
;; dotnet store
;; ???
(defun sharper--repeat-description (the-var)
"Format the command in THE-VAR for display in the main transient.
THE-VAR is one of the sharper--last-* variables, except `sharper--last-run'."
(if (not the-var)
(propertize "[No previous invocation]"
'face
font-lock-doc-face)
(concat "repeast last: "
(propertize (cdr the-var)
'face
font-lock-doc-face))))
(defun sharper--repeat-description-run (the-var)
"Format the command in THE-VAR for display in the main transient.
THE-VAR is `sharper--last-run' but the plan is for all of them to change format."
(if (not the-var)
(propertize "[No previous invocation]"
'face
font-lock-doc-face)
(concat "repeast last: "
(propertize (cl-second the-var)
'face
font-lock-doc-face))))
(defun sharper--test-point-setup ()
"Setup the state to run the test at point.
Updates `sharper--current-test' using `sharper--current-method-function-name'
and `sharper--nearest-project-dir', then returns the description to show in
the main transient."
(let ((the-thing (sharper--current-method-function-name))
(proj-dir (sharper--nearest-project-dir)))
;; update the variable, will have a value only if
;; we could find the dir and a function name
(setq sharper--current-test
(when (and the-thing proj-dir)
(cons proj-dir the-thing)))
(if sharper--current-test
(concat "test method: "
(propertize the-thing
'face
font-lock-type-face)
(propertize (concat " (project " proj-dir ")")
'face
font-lock-doc-face))
(propertize "[Can't identify test & project to run]"
'face
font-lock-doc-face))))
(defun sharper--build-nearest-setup ()
"Setup the state to build the nearest project.
Updates `sharper--current-build' using `sharper--nearest-project-dir',
then returns the description to show in the main transient."
(let ((proj-dir (sharper--nearest-project-dir)))
;; update the variable, will have a value only if
;; we could find the nearest project
(setq sharper--current-build proj-dir)
(if sharper--current-build
(concat "build nearest"
(propertize (concat " (project " proj-dir ")")
'face
font-lock-doc-face))
(propertize "[Can't identify project to build]"
'face
font-lock-doc-face))))
(defun sharper--current-method-function-name ()
"Return the name of the current method/function.
As last resort it uses the word at point.
The current implementation is C# only, we need to make accomodations for F#."
;; c-defun-name-and-limits is an undocumentd cc-mode function.
;; It works, but, who knows?
;; In case if fails, we resort to the word at point, however
;; 'word is not valid when subword-mode is enabled, using
;; instead 'sexp makes it work in both cases
(let ((c-name (ignore-errors
(when (fboundp 'c-defun-name-and-limits)
(c-defun-name-and-limits nil))))
(fallback (thing-at-point 'sexp t)))
(if c-name
(car c-name) ;; nothing else to do!
(if (> (length fallback) 125) ;; make sure the fallback is not too long
;; See issue #24. Before we would take only 50 chars and add "(...)" but
;; that broke the --filter parameter. I only added this because one time I
;; had a giant block of text under point and it made the transient look
;; horrible. Using a 125 char limit is realistic, and not adding ellipsis
;; will keep --filter working.
(substring fallback 0 125)
fallback))))
(defun sharper--nearest-project-dir ()
"Return the first directory that has a project from the current path."
;; TODO: I think this would be more useful if it returned the full project path.
;; TODO: Modes other than dired where this would be logical???
(let ((start-from (or (buffer-file-name)
(when (eq major-mode
'dired-mode)
default-directory))))
(when start-from
(locate-dominating-file
start-from
#'sharper--directory-has-proj-p))))
(defun sharper--run-last-build (&optional _transient-params)
"Run \"dotnet build\", ignore TRANSIENT-PARAMS, repeat last call via `sharper--last-build'."
(interactive
(list (transient-args 'sharper-transient-build)))
(transient-set)
(if sharper--last-build
(let ((default-directory (car sharper--last-build))
(command (cdr sharper--last-build)))
(sharper--log-command "Build" command)
(compile command))
(sharper-transient-build)))
(defun sharper--run-nearest-build (&optional _transient-params)
"Run \"dotnet build\", ignore TRANSIENT-PARAMS, setup call via `sharper--current-build'."
(interactive
(list (transient-args 'sharper-transient-build)))
(transient-set)
(if sharper--current-build
(let ((default-directory sharper--current-build)
(command (sharper--strformat sharper--build-template
?t ""
?o ""
?m "")))
(sharper--log-command "Build nearest project" command)
(compile command))
;; go back to the main menu if sharper--current-build is not set
(sharper-main-transient)))
(defun sharper--run-last-test (&optional _transient-params)
"Run \"dotnet test\", ignore TRANSIENT-PARAMS, repeat last call via `sharper--last-test'."
(interactive
(list (transient-args 'sharper-transient-test)))
(transient-set)
(if sharper--last-test
(let ((default-directory (car sharper--last-test))
(command (cdr sharper--last-test)))
(sharper--log-command "Test" command)
;; Issue #27: if the command is "dotnet watch test" then run it in an async
;; shell instead of `compilation-mode'.
(if (string-prefix-p "dotnet watch test" command)
;; Run with "watch" as async shell
(pop-to-buffer (sharper--run-async-shell command
;; No project name available...
(format "*dotnet test - %s*" default-directory)
'confirm-kill-process))
;; run using compilation-mode
(compile command)))
;; I ask forgiveness for my sin of nesting "(if" to unreadable levels. This is the else branch
;; of line 352
(sharper-transient-test)))
(defun sharper--run-test-at-point (&optional _transient-params)
"Run \"dotnet test\", ignore TRANSIENT-PARAMS, setup call via `sharper--current-test'."
(interactive
(list (transient-args 'sharper-transient-test)))
(transient-set)
(if sharper--current-test
(let ((default-directory (car sharper--current-test))
(command (sharper--strformat sharper--test-methodfunc-template
?e (shell-quote-argument (cdr sharper--current-test)))))
(sharper--log-command "Testing method/function at point" command)
(compile command))
;; go back to the main menu if sharper--current-test is not set
(sharper-main-transient)))
(defun sharper--run-last-publish (&optional _transient-params)
"Run \"dotnet publish\", ignore TRANSIENT-PARAMS, repeat last call via `sharper--last-publish'."
(interactive
(list (transient-args 'sharper-transient-publish)))
(transient-set)
(if sharper--last-publish
(let ((default-directory (car sharper--last-publish))
(command (cdr sharper--last-publish)))
(sharper--log-command "Publish" command)
(pop-to-buffer (sharper--set-static-output-buffer (sharper--run-async-shell command "*dotnet publish*"))))
(sharper-transient-publish)))
(defun sharper--run-last-pack (&optional _transient-params)
"Run \"dotnet build\", ignore TRANSIENT-PARAMS, repeat last call via `sharper--last-pack'."
(interactive
(list (transient-args 'sharper-transient-pack)))
(transient-set)
(if sharper--last-pack
(let ((default-directory (car sharper--last-pack))
(command (cdr sharper--last-pack)))
(sharper--log-command "Pack" command)
(compile command))
(sharper-transient-pack)))
(defun sharper--run-last-run (&optional _transient-params)
"Run \"dotnet run\", ignore TRANSIENT-PARAMS, repeat last call via `sharper--last-run'."
(interactive
(list (transient-args 'sharper-transient-run)))
(transient-set)
(if sharper--last-run
(cl-destructuring-bind (default-directory command proj-name) sharper--last-run
(sharper--log-command "Run" command)
(pop-to-buffer (sharper--run-async-shell command
(format "*dotnet run - %s*" proj-name)
(when sharper-run-only-one
'confirm-kill-process))))
(sharper-transient-run)))
(defun sharper--version-info ()
"Display version info for SDKs, runtimes, etc."
(interactive)
(sharper--message "Compiling \"dotnet\" information...")
(let ((dotnet-path (file-chase-links (executable-find "dotnet")))
(dotnet-info (shell-command-to-string "dotnet --info"))
(buf (get-buffer-create "*dotnet info*")))
(with-current-buffer buf
(erase-buffer)
(insert "dotnet path: "
dotnet-path
"\n")
(insert "\ndotnet --info output:\n\n"
dotnet-info)
(pop-to-buffer (sharper--set-static-output-buffer buf)))))
;;------------------Argument parsing----------------------------------------------
(defun sharper--get-target (transient-params)
"Extract from TRANSIENT-PARAMS the \"TARGET\" argument."
(sharper--get-argument "<TARGET>=" transient-params))
(defun sharper--get-argument (marker transient-params)
"Extract from TRANSIENT-PARAMS the argument with MARKER."
(cl-some
(lambda (an-arg) (when (string-prefix-p marker an-arg)
(replace-regexp-in-string marker
""
an-arg)))
transient-params))
(defun sharper--option-split-quote (an-option)
"Split AN-OPTION and shell quote its value, or return it as-if if it is a string."
(let* ((equal-char-index (string-match "=" an-option))
(name (substring an-option 0 equal-char-index)))
(if equal-char-index
(cons name
;; quoting of the value for the parameter happens
;; later in sharper--option-alist-to-string
(substring an-option (+ 1 equal-char-index)))
name)))
(defun sharper--only-options (transient-params)
"Extract from TRANSIENT-PARAMS the options (ie, start with -)."
(mapcar #'sharper--option-split-quote
(cl-remove-if-not (lambda (arg) (string-prefix-p "-" arg))
transient-params)))
(defun sharper--option-alist-to-string (options)
"Convert the OPTIONS as parsed by `sharper--only-options' to a string."
;; Right now the alist intermediate step seems useless. But I think the alist
;; is a good idea in case we ever need to massage the parameters :)
(mapconcat (lambda (str-or-pair)
(if (consp str-or-pair)
(concat (car str-or-pair) " " (shell-quote-argument (cdr str-or-pair)))
str-or-pair))
options
" "))
(defun sharper--shell-quote-or-empty (param)
"If PARAM nil or empty string, return empty string, else shell-quote PARAM."
(if (or (string-empty-p param)
(not param))
""
(shell-quote-argument param)))
;;------------------format-spec facilities----------------------------------------
(defun sharper--strformat (template-name &rest args)
"Apply `format-spec' to TEMPLATE-NAME using ARGS as key-value pairs.
Just a facility to make these invocations shorter."
(format-spec template-name
(sharper--as-alist args)))
(defun sharper--as-alist (the-list)
"Convert THE-LIST to an alist by looping over the elements by pairs."
;; From this SO answer https://stackoverflow.com/a/19774752/91877
(cl-loop for (head . tail) on the-list by 'cddr
collect (cons head (car tail))))
;;------------------dotnet common-------------------------------------------------
(defun sharper--project-root (&optional path)
"Get the project root from optional PATH or `default-directory'."
;; NOTE: project-root is available from EMACS28, fot the rest a workaround is used
;; based on `project' package internals.
(cl-letf ((proj (project-current nil
(or path
default-directory))))
(if (fboundp 'project-root) ;; EMACS > 27
(project-root proj)
;; `project-current' returns two elements in older versions of `project', for example
;; (Git "/the/project/dir"). In newer versions, it returns an extra first element, for
;; example (vc Git "/the/project/dir"). The old code returned `cdr', rather than
;; checking list size let's return the last element, but experience shows this code is
;; fickle... (see older commits for a previous warning on this humble function)
(cl-typecase proj
(list (car (last proj)))
(cons (cdr proj))))))
(defun sharper--filename-proj-or-sln-p (filename)
"Return non-nil if FILENAME is a project or solution."
(let ((extension (file-name-extension filename)))
(or
(string= "sln" extension)
(member extension sharper-project-extensions))))
(defun sharper--filename-proj-p (filename)
"Return non-nil if FILENAME is a project."
(let ((extension (file-name-extension filename)))
(member extension sharper-project-extensions)))
(defun sharper--directory-has-proj-p (directory)
"Return non-nil if DIRECTORY has a project."
;; It is tempting to use a regex to filter the project names
;; but for now let's rely on the existing function, for consistency
(when (file-directory-p directory)
;; Strangely enough, in Windows directory-files ignores a path
;; that is a file, but under Linux it fails. Adding a guard...
(let ((files (directory-files directory t)))
(cl-some #'sharper--filename-proj-p files))))
(defun sharper--filename-sln-p (filename)
"Return non-nil if FILENAME is a solution."
(let ((extension (file-name-extension filename)))
(string= "sln" extension)))
(defun sharper--read-solution-or-project ()
"Offer completion for project or solution files under the current project's root."
(let ((all-files (project-files (project-current t))))
(completing-read "Select project or solution: "
all-files
#'sharper--filename-proj-or-sln-p)))
(defun sharper--read--project ()
"Offer completion for project files under the current project's root."
(let ((all-files (project-files (project-current t))))
(completing-read "Select project: "
all-files
#'sharper--filename-proj-p)))
(defun sharper--read-solution ()
"Offer completion for solution files under the current project's root."
(let ((all-files (project-files (project-current t))))
(completing-read "Select a solution: "
all-files
#'sharper--filename-sln-p)))
;; TODO: it would be really nice if this validated the format
(defun sharper--read-msbuild-properties ()
"Read name-value pairs of MSBuild property strings."
(let ((user-input (read-string
"Enter the MSBuild properties in the format p1=v1 p2=v2...pN=vN: ")))
(mapconcat
(lambda (pair)
(concat "-p:" pair))
(split-string user-input)
" ")))
(transient-define-argument sharper--option-target-projsln ()
:description "<PROJECT>|<SOLUTION>"
:class 'transient-option
:shortarg "T"
:argument "<TARGET>="
:reader (lambda (_prompt _initial-input _history)
(sharper--read-solution-or-project)))
(transient-define-argument sharper--option-target-proj ()
:description "<PROJECT>"
:class 'transient-option
:shortarg "T"
:argument "<TARGET>="
:reader (lambda (_prompt _initial-input _history)
(sharper--read--project)))
(transient-define-argument sharper--option-msbuild-params ()
:description "<MSBuildProperties>"
:class 'transient-option
:shortarg "-p"
:argument "<MSBuildProperties>="
:reader (lambda (_prompt _initial-input _history)
(sharper--read-msbuild-properties)))
(defun sharper--run-async-shell (command buffer-name &optional buffer-reuse-behaviour)
"Call `async-shell-command' to run COMMAND using a buffer BUFFER-NAME.
Returns a reference to the output buffer.
The optional parameter BUFFER-REUSE-BEHAVIOUR allows for let-binding
`async-shell-command-buffer'. When not specified, a new buffer is created on
each call."
(let ((le-buffer (get-buffer-create
;; unless the caller assumes control via the optional parameter, we
;; will create a unique buffer name for them - the default.
(if buffer-reuse-behaviour
buffer-name
(generate-new-buffer-name buffer-name))))
(async-shell-command-buffer (or buffer-reuse-behaviour 'confirm-new-buffer)))
(async-shell-command command le-buffer le-buffer)
le-buffer))
(defun sharper--shell-command-to-log (command)
"Call `shell-command-to-string' to run COMMAND. Log the output."
(sharper--message "Running shell command...")
(let ((cmd-output (shell-command-to-string command)))
(sharper--log "[Command output]" "\n" cmd-output "\n"))
;; clear the echo area after we are done
(message nil))
(defun sharper--list-solproj-all-packages ()
"List ALL packages of the solution/project open in the calling buffer.
The solution or project is determined via the buffer local variables.
\"ALL\" packages refers to including the transitive references."
(interactive)
(let* ((slnproj (or sharper--solution-path sharper--project-path))
(default-directory (file-name-directory slnproj))
(command (sharper--strformat sharper--package-list-transitive-template
?t (shell-quote-argument slnproj))))
(sharper--log-command "List solution/project packages (incl. transitive)" command)
(sharper--set-static-output-buffer (sharper--run-async-shell command
(concat "*packages (full) "
(file-name-nondirectory slnproj)
"*")))))
(defun sharper--get-RIDs ()
"Obtain the list of Runtimes IDs, format and return it.
After the first call, the list is cached in `sharper--cached-RIDs'."
(unless sharper--cached-RIDs
(let ((json-data (sharper--json-request sharper-RIDs-URL)))
(unless (eq json-data 'error)
(setq sharper--cached-RIDs
(mapcar #'car
(alist-get 'runtimes json-data))))))
sharper--cached-RIDs)
(transient-define-argument sharper--option-target-runtime ()
:description "Target runtime"
:class 'transient-option
:shortarg "-r"
:argument "--runtime="
:reader (lambda (_prompt _initial-input _history)
(completing-read "Runtime: "
(sharper--get-RIDs))))
;;------------------dotnet build--------------------------------------------------
(defun sharper--build (&optional transient-params)
"Run \"dotnet build\" using TRANSIENT-PARAMS as arguments & options."
(interactive
(list (transient-args 'sharper-transient-build)))
(transient-set)
(let* ((target (sharper--get-target transient-params))
(options (sharper--only-options transient-params))
(msbuild-props (sharper--get-argument "<MSBuildProperties>=" transient-params))
;; We want *compilation* to happen at the root directory
;; of the selected project/solution
(directory (sharper--project-root target)))
(unless target ;; it is possible to build without a target :shrug:
(sharper--message "No TARGET provided, will build in default directory."))
(let ((command (sharper--strformat sharper--build-template
?t (sharper--shell-quote-or-empty target)
?o (sharper--option-alist-to-string options)
?m (sharper--shell-quote-or-empty msbuild-props))))
(setq sharper--last-build (cons directory command))
(sharper--run-last-build))))
(transient-define-prefix sharper-transient-build ()
"dotnet build menu"
:value '("--configuration=Debug" "--verbosity=minimal")
["Common Arguments"
(sharper--option-target-projsln)
("-c" "Configuration" "--configuration=")
("-v" "Verbosity" "--verbosity=")]
["Other Arguments"
("-w" "Framework" "--framework=")
("-o" "Output" "--output=")
("-ni" "No incremental" "--no-incremental")
("-nd" "No dependencies" "--no-dependencies")
(sharper--option-target-runtime)
(sharper--option-msbuild-params)
("-s" "NuGet Package source URI" "--source")
("-es" "Version suffix" "--version-suffix=")]
["Actions"
("b" "build" sharper--build)
("q" "quit" transient-quit-all)])
;;------------------dotnet test---------------------------------------------------
(defun sharper--test (&optional transient-params)
"Run \"dotnet test\" using TRANSIENT-PARAMS as arguments & options."
(interactive
(list (transient-args 'sharper-transient-test)))
(transient-set)
(let* ((target (sharper--get-target transient-params))
(options (sharper--only-options transient-params))
(run-settings (sharper--get-argument "<RunSettings>=" transient-params))
;; We want *compilation* to happen at the root directory
;; of the selected project/solution
;; update for issue #27: if the command is "dotnet watch test" then
;; use the target's directory
(directory (if (string-prefix-p "dotnet watch test" sharper--test-template)
(file-name-directory (or target ""))
(sharper--project-root target))))
(unless target ;; it is possible to test without a target :shrug:
;; this has always been finicky a I guess #27 will make things worse
(sharper--message "No TARGET provided, will run tests in default directory."))
(let ((command (sharper--strformat sharper--test-template
?t (sharper--shell-quote-or-empty target)
?o (sharper--option-alist-to-string options)
?a (if run-settings
(concat "-- " run-settings)
""))))
(setq sharper--last-test (cons directory command))
(sharper--run-last-test))))
(defun sharper--watch-test (&optional transient-params)
"Run \"dotnet watch test\" using TRANSIENT-PARAMS as arguments & options."
(interactive
(list (transient-args 'sharper-transient-test)))
(transient-set)
;; Issue #27: add support for "dotnet watch". Rebind the command template and
;; call the existing `sharper--test' function.
;; HOWEVER, there's a bit of a hack in `sharper--run-last-test' to run
;; the command using `sharper--run-async-shell' instead of `compilation-mode'
;; since the watch version is not "run and done".
(let ((sharper--test-template sharper--watch-test-template))
(sharper--test transient-params)))
(transient-define-argument sharper--option-test-runsettings ()
:description "<RunSettings>"
:class 'transient-option
:shortarg "rs"
:argument "<RunSettings>="
:reader (lambda (_prompt _initial-input _history)
(read-string "RunSettings arguments: ")))
(transient-define-prefix sharper-transient-test ()
"dotnet test menu"
:value '("--configuration=Debug" "--verbosity=minimal")
["Common Arguments"
(sharper--option-target-projsln)
("-c" "Configuration" "--configuration=")
("-v" "Verbosity" "--verbosity=")
("-f" "Filter" "--filter=")
("-l" "Logger" "--logger=")
("-t" "List tests discovered""--list-tests")
("-nb" "No build" "--no-build")]
["Other Arguments"
("-b" "Blame" "--blame")
("-a" "Test adapter path" "--test-adapter-path=")
("-w" "Framework" "--framework=")
("-b" "Blame" "--blame")
("-o" "Output" "--output=")
("-O" "Data collector name" "--collect")
("-d" "Diagnostics file" "--diag=")
("-nr" "No restore" "--no-restore")
(sharper--option-target-runtime)
("-R" "Results directory" "--results-directory=")
("-s" "Settings" "--settings=")
("-es" "Version suffix" "--version-suffix=")
(sharper--option-test-runsettings)]
["Actions"
("t" "test" sharper--test)
("w" "watch test" sharper--watch-test)
("q" "quit" transient-quit-all)])
;;------------------dotnet clean--------------------------------------------------
(defun sharper--clean (&optional transient-params)
"Run \"dotnet clean\" using TRANSIENT-PARAMS as arguments & options."
(interactive
(list (transient-args 'sharper-transient-clean)))
(transient-set)
(let* ((target (sharper--get-target transient-params))
(options (sharper--only-options transient-params))
;; We want async-shell-command to happen at the root directory
;; of the selected project/solution
(default-directory (sharper--project-root target)))
(unless target ;; it is possible to build without a target :shrug:
(sharper--message "No TARGET provided, will run clean in default directory."))
(let ((command (sharper--strformat sharper--clean-template
?t (sharper--shell-quote-or-empty target)
?o (sharper--option-alist-to-string options))))
(sharper--log "Clean command\n" command "\n")
(pop-to-buffer (sharper--set-static-output-buffer (sharper--run-async-shell command "*dotnet clean*"))))))
(transient-define-prefix sharper-transient-clean ()
"dotnet clean menu"
:value '("--configuration=Debug" "--verbosity=normal")
["Common Arguments"
(sharper--option-target-projsln)
("-c" "Configuration" "--configuration=")
("-v" "Verbosity" "--verbosity=")]
["Other Arguments"
("-w" "Framework" "--framework=")
("-o" "Output" "--output=")
(sharper--option-target-runtime)]
["Actions"
("c" "clean" sharper--clean)
("q" "quit" transient-quit-all)])
;;------------------dotnet publish------------------------------------------------
(defun sharper--publish (&optional transient-params)
"Run \"dotnet publish\" using TRANSIENT-PARAMS as arguments & options."
(interactive
(list (transient-args 'sharper-transient-publish)))
(transient-set)
(let* ((target (sharper--get-target transient-params))
(options (sharper--only-options transient-params))
;; We want async-shell-command to happen at the root directory
;; of the selected project/solution
(directory (sharper--project-root target)))
(unless target ;; it is possible to test without a target :shrug:
(sharper--message "No TARGET provided, will run tests in default directory."))
(let ((command (sharper--strformat sharper--publish-template
?t (sharper--shell-quote-or-empty target)
?o (sharper--option-alist-to-string options))))
(setq sharper--last-publish (cons directory command))
(sharper--run-last-publish))))
(transient-define-prefix sharper-transient-publish ()
"dotnet publish menu"
:value '("--configuration=Debug" "--verbosity=minimal")
["Common Arguments"
(sharper--option-target-projsln)
("-c" "Configuration" "--configuration=")
("-v" "Verbosity" "--verbosity=")]
["Other Arguments"
("-f" "Force" "--force")
("-w" "Framework" "--framework=")
(sharper--option-target-runtime)
("-o" "Output" "--output=")
;; There are somewhat odd rules governing these two
;; easier to include both self contained flags and
;; have users make sense of them
("-sf" "Self contained" "--self-contained")
("-ns" "No self contained" "--no-self-contained")
("-nb" "No build" "--no-build")
("-nd" "No dependencies" "--no-dependencies")
("-nr" "No restore" "--no-restore")
("-es" "Version suffix" "--version-suffix=")]
["Actions"
("p" "publish" sharper--publish)
("q" "quit" transient-quit-all)])
;;------------------dotnet pack---------------------------------------------------
(defun sharper--pack (&optional transient-params)
"Run \"dotnet pack\" using TRANSIENT-PARAMS as arguments & options."
(interactive
(list (transient-args 'sharper-transient-pack)))
(transient-set)
(let* ((target (sharper--get-target transient-params))
(options (sharper--only-options transient-params))
(msbuild-props (sharper--get-argument "<MSBuildProperties>=" transient-params))
;; We want *compilation* to happen at the root directory
;; of the selected project/solution
(directory (sharper--project-root target)))
(unless target ;; it is possible to build without a target :shrug:
(sharper--message "No TARGET provided, will pack in default directory."))
(let ((command (sharper--strformat sharper--pack-template
?t (sharper--shell-quote-or-empty target)
?o (sharper--option-alist-to-string options)
?m (sharper--shell-quote-or-empty msbuild-props))))
(setq sharper--last-pack (cons directory command))
(sharper--run-last-pack))))
(transient-define-prefix sharper-transient-pack ()
"dotnet build menu"
:value '("--configuration=Debug" "--verbosity=minimal")
["Common Arguments"
(sharper--option-target-projsln)
("-c" "Configuration" "--configuration=")
("-v" "Verbosity" "--verbosity=")]
["Other Arguments"
("-f" "Force" "--force")
("-w" "Framework" "--framework=")
(sharper--option-target-runtime)
("-o" "Output" "--output=")
(sharper--option-msbuild-params)
("-is" "Include source" "--include-source")
("-iy" "Include symbols" "--include-symbols")
("-nb" "No build" "--no-build")
("-nd" "No dependencies" "--no-dependencies")
("-nr" "No restore" "--no-restore")
("-s" "Serviceable" "--serviceable")
("-es" "Version suffix" "--version-suffix=")]
["Actions"
("p" "pack" sharper--pack)
("q" "quit" transient-quit-all)])
;;------------------dotnet run----------------------------------------------------
(defun sharper--run (&optional transient-params)
"Run \"dotnet run\" using TRANSIENT-PARAMS as arguments & options."
(interactive
(list (transient-args 'sharper-transient-run)))
(transient-set)
(let* ((target (sharper--get-target transient-params))
(options (sharper--only-options transient-params))
(app-args (sharper--get-argument "<ApplicationArguments>=" transient-params))
;; For run we want this to execute in the same directory
;; that the project is, where the .settings file is
(directory (file-name-directory (or target "")))
;; Default value, overriden if there's a target project
(proj-name (file-name-directory default-directory)))
(if target
;; when there is a target, extract the project name
(setq proj-name (file-name-sans-extension (file-name-nondirectory target)))
;; it is possible to run without a target :shrug:
(sharper--message "No TARGET provided, will run in default directory."))
(let ((command (sharper--strformat sharper--run-template
?t (sharper--shell-quote-or-empty target)
?o (sharper--option-alist-to-string options)
?a (if app-args
(concat "-- " app-args)
""))))
(setq sharper--last-run (list (or directory default-directory)
command
proj-name))
(sharper--run-last-run))))
(defun sharper--watch-run (&optional transient-params)
"Run \"dotnet watch run\" using TRANSIENT-PARAMS as arguments & options."
(interactive
(list (transient-args 'sharper-transient-run)))
(transient-set)
;; Issue #27: add support for "dotnet watch". Rebind the command template and
;; call the existing `sharper--run' function, everything else stays the same.
(let ((sharper--run-template sharper--watch-run-template))
(sharper--run transient-params)))
(transient-define-argument sharper--option-run-application-arguments ()
:description "Application arguments"
:class 'transient-option
:shortarg "aa"
:argument "<ApplicationArguments>="
:reader (lambda (_prompt _initial-input _history)
(read-string "Application arguments: ")))
(transient-define-prefix sharper-transient-run ()
"dotnet run menu"
:value '("--configuration=Debug" "--verbosity=minimal")
["Common Arguments"
(sharper--option-target-proj)
(sharper--option-run-application-arguments)
("-c" "Configuration" "--configuration=")
("-v" "Verbosity" "--verbosity=")]
["Other Arguments"
("-lp" "Launch profile" "--launch-profile=")
("-f" "Force" "--force")
("-w" "Framework" "--framework=")
(sharper--option-target-runtime)
("-o" "Output" "--output=")
("-nl" "No launch profile" "--no-launch-profile")
("-nr" "No restore" "--no-restore")
("-nb" "No build" "--no-build")
("-nd" "No dependencies" "--no-dependencies")]
["Actions"
("r" "run" sharper--run)
("w" "watch run" sharper--watch-run)
("q" "quit" transient-quit-all)])
;;------------------dotnet solution management------------------------------------
(defun sharper--format-solution-projects (path)
"Get and format the projects for the solution in PATH for `sharper--solution-management-mode'."
(cl-labels ((convert-to-entry (project)
(list project (vector project))))
(let ((command (sharper--strformat sharper--sln-list-template
?t (shell-quote-argument path))))
(sharper--log-command "List solution projects" command)
(mapcar #'convert-to-entry
(nthcdr 2 (split-string (shell-command-to-string command)
"\n" t))))))
(defun sharper--manage-solution ()
"Prompt for a solution and start `sharper--solution-management-mode' for it."