Skip to content

Commit a0ffd92

Browse files
authored
Introduce the snippet target type (#3694)
Snippets are small, focused pieces of example code for packages. They are meant for current and potential package clients to read and try when exploring a package. Snippets build with the rest of a package so they don't get stale and can run as an executable target with hidden "demo code" to illustrate and prove that the snippets work (this does not mean that snippets are tests!). Summary of changes: - Add a `.snippet` product and target type - Add the `Snippet` and `SnippetGroup` model to PackageModel - When building a package in `PackageBuilder`, look for snippets in the `Snippets` subdirectory. - Add the "card stack" terminal UIs for browsing a package's snippets - "Top" card: The top level menu that displays products and snippet groups - "Snippet Group" card: The menu that shows the snippets in a group - "Snippet" card: Displays a snippet's presentation code and offers to build and run the snippet.
1 parent dd71675 commit a0ffd92

28 files changed

+1289
-28
lines changed

Sources/Build/BuildPlan.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1214,7 +1214,7 @@ public final class ProductBuildDescription {
12141214
let relativePath = "@rpath/\(buildParameters.binaryRelativePath(for: product).pathString)"
12151215
args += ["-Xlinker", "-install_name", "-Xlinker", relativePath]
12161216
}
1217-
case .executable:
1217+
case .executable, .snippet:
12181218
// Link the Swift stdlib statically, if requested.
12191219
if buildParameters.shouldLinkStaticSwiftStdlib {
12201220
if buildParameters.triple.isDarwin() {
@@ -1256,7 +1256,7 @@ public final class ProductBuildDescription {
12561256
if containsSwiftTargets {
12571257
switch product.type {
12581258
case .library, .plugin: break
1259-
case .test, .executable:
1259+
case .test, .executable, .snippet:
12601260
if buildParameters.triple.isDarwin() {
12611261
let stdlib = buildParameters.toolchain.macosSwiftStdlib
12621262
args += ["-Xlinker", "-rpath", "-Xlinker", stdlib.pathString]
@@ -1732,7 +1732,7 @@ public class BuildPlan {
17321732
switch product.type {
17331733
case .library(.automatic), .library(.static), .plugin:
17341734
return product.targets.map { .target($0, conditions: []) }
1735-
case .library(.dynamic), .test, .executable:
1735+
case .library(.dynamic), .test, .executable, .snippet:
17361736
return []
17371737
}
17381738
}
@@ -1754,7 +1754,7 @@ public class BuildPlan {
17541754
// In tool version .v5_5 or greater, we also include executable modules implemented in Swift in
17551755
// any test products... this is to allow testing of executables. Note that they are also still
17561756
// built as separate products that the test can invoke as subprocesses.
1757-
case .executable:
1757+
case .executable, .snippet:
17581758
if product.targets.contains(target) {
17591759
staticTargets.append(target)
17601760
} else if product.type == .test && target.underlyingTarget is SwiftTarget {

Sources/Build/ManifestBuilder.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -554,7 +554,7 @@ extension LLBuildManifestBuilder {
554554

555555
case .product(let product, _):
556556
switch product.type {
557-
case .executable, .library(.dynamic):
557+
case .executable, .snippet, .library(.dynamic):
558558
guard let planProduct = plan.productMap[product] else {
559559
throw InternalError("unknown product \(product)")
560560
}
@@ -673,7 +673,7 @@ extension LLBuildManifestBuilder {
673673

674674
case .product(let product, _):
675675
switch product.type {
676-
case .executable, .library(.dynamic):
676+
case .executable, .snippet, .library(.dynamic):
677677
guard let planProduct = plan.productMap[product] else {
678678
throw InternalError("unknown product \(product)")
679679
}
@@ -852,7 +852,7 @@ extension ResolvedProduct {
852852
return "\(name)-\(config).a"
853853
case .library(.automatic):
854854
throw InternalError("automatic library not supported")
855-
case .executable:
855+
case .executable, .snippet:
856856
return "\(name)-\(config).exe"
857857
case .plugin:
858858
throw InternalError("unexpectedly asked for the llbuild target name of a plugin product")

Sources/Commands/CMakeLists.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ add_library(Commands
1414
MultiRootSupport.swift
1515
Options.swift
1616
show-dependencies.swift
17+
Snippets/CardEvent.swift
18+
Snippets/Cards/SnippetCard.swift
19+
Snippets/Cards/SnippetGroupCard.swift
20+
Snippets/Cards/TopCard.swift
21+
Snippets/CardStack.swift
22+
Snippets/Card.swift
23+
Snippets/Colorful.swift
1724
SwiftBuildTool.swift
1825
SwiftPackageCollectionsTool.swift
1926
SwiftPackageRegistryTool.swift

Sources/Commands/Snippets/Card.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2014 - 2021 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See http://swift.org/LICENSE.txt for license information
8+
See http://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
/// A terminal menu that takes up the whole terminal, accepting input and
12+
/// rendering in response.
13+
protocol Card {
14+
/// Render the contents to be printed to the terminal.
15+
func render() -> String
16+
17+
/// Accept a line of input from the user's terminal and provide
18+
/// an optional ``CardEvent`` which can alter the card stack.
19+
func acceptLineInput<S: StringProtocol>(_ line: S) -> CardEvent?
20+
21+
/// The input prompt to present to the user when accepting a line of input.
22+
var inputPrompt: String? { get }
23+
}
24+
25+
extension Card {
26+
var defaultPrompt: String {
27+
return "Press enter to continue."
28+
}
29+
}
30+
31+
extension Card {
32+
var inputPrompt: String? {
33+
return defaultPrompt
34+
}
35+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2014 - 2021 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See http://swift.org/LICENSE.txt for license information
8+
See http://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
/// An event that alters the card stack after some input from the user.
12+
enum CardEvent {
13+
/// Pop the top card from the stack, providing an optional error that
14+
/// may have occurred.
15+
case pop(Swift.Error? = nil)
16+
17+
/// Push a new card onto the stack.
18+
case push(Card)
19+
20+
/// Quit the program, providing an optional error that may have occurred.
21+
case quit(Swift.Error? = nil)
22+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2014 - 2021 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See http://swift.org/LICENSE.txt for license information
8+
See http://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import PackageModel
12+
import TSCBasic
13+
import PackageGraph
14+
15+
fileprivate extension TerminalController {
16+
func clearScreen() {
17+
write("\u{001b}[2J")
18+
write("\u{001b}[H")
19+
flush()
20+
}
21+
}
22+
23+
/// A stack of "cards" to display one at a time at the command line.
24+
struct CardStack {
25+
var terminal: TerminalController
26+
27+
/// The representation of a stack of cards.
28+
var cards = [Card]()
29+
30+
/// The tool used for eventually building and running a chosen snippet.
31+
var swiftTool: SwiftTool
32+
33+
/// When true, the escape sequence for clearing the terminal should be
34+
/// printed first.
35+
private var needsToClearScreen = true
36+
37+
init(package: ResolvedPackage, snippetGroups: [SnippetGroup], swiftTool: SwiftTool) {
38+
self.terminal = TerminalController(stream: stdoutStream)!
39+
self.cards = [TopCard(package: package, snippetGroups: snippetGroups, swiftTool: swiftTool)]
40+
self.swiftTool = swiftTool
41+
}
42+
43+
mutating func push(_ card: Card) {
44+
cards.append(card)
45+
}
46+
47+
mutating func pop() {
48+
cards.removeLast()
49+
}
50+
51+
mutating func clear() {
52+
cards.removeAll()
53+
}
54+
55+
func askForLineInput(prompt: String?) -> String? {
56+
if let prompt = prompt {
57+
print(brightBlack { prompt }.terminalString())
58+
}
59+
terminal.write(">>> ", inColor: .green, bold: true)
60+
return readLine(strippingNewline: true)
61+
}
62+
63+
mutating func run() {
64+
var inputFinished = false
65+
while !inputFinished {
66+
guard let top = cards.last else {
67+
break
68+
}
69+
70+
if needsToClearScreen {
71+
terminal.clearScreen()
72+
needsToClearScreen = false
73+
}
74+
75+
print(top.render())
76+
77+
// Assume input finished until proven otherwise, i.e. when readLine returns
78+
// `nil`.
79+
inputFinished = true
80+
81+
askForLine: while let line = askForLineInput(prompt: top.inputPrompt) {
82+
inputFinished = false
83+
let trimmedLine = String(line.drop { $0.isWhitespace }
84+
.reversed()
85+
.drop { $0.isWhitespace }
86+
.reversed())
87+
let response = top.acceptLineInput(trimmedLine)
88+
switch response {
89+
case .none:
90+
continue askForLine
91+
case .push(let card):
92+
push(card)
93+
needsToClearScreen = true
94+
break askForLine
95+
case let .pop(error):
96+
cards.removeLast()
97+
if let error = error {
98+
swiftTool.diagnostics.emit(error: error.localizedDescription)
99+
needsToClearScreen = false
100+
} else {
101+
needsToClearScreen = !cards.isEmpty
102+
}
103+
break askForLine
104+
case .quit:
105+
return
106+
}
107+
}
108+
}
109+
}
110+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2014 - 2021 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See http://swift.org/LICENSE.txt for license information
8+
See http://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import PackageModel
12+
import TSCBasic
13+
14+
/// A card displaying a ``Snippet`` at the terminal.
15+
struct SnippetCard: Card {
16+
enum Error: Swift.Error, CustomStringConvertible {
17+
case cantRunSnippet(reason: String)
18+
19+
var description: String {
20+
switch self {
21+
case let .cantRunSnippet(reason):
22+
return "Can't run snippet: \(reason)"
23+
}
24+
}
25+
}
26+
/// The snippet to display in the terminal.
27+
var snippet: Snippet
28+
29+
/// The snippet's index within its group.
30+
var number: Int
31+
32+
/// The tool used for eventually building and running a chosen snippet.
33+
var swiftTool: SwiftTool
34+
35+
func render() -> String {
36+
var rendered = colorized {
37+
brightYellow {
38+
"# "
39+
snippet.name
40+
}
41+
"\n\n"
42+
}.terminalString()
43+
44+
if !snippet.explanation.isEmpty {
45+
rendered += brightBlack {
46+
snippet.explanation
47+
.split(separator: "\n", omittingEmptySubsequences: false)
48+
.map { "// " + $0 }
49+
.joined(separator: "\n")
50+
}.terminalString()
51+
52+
rendered += "\n\n"
53+
}
54+
55+
rendered += snippet.presentationCode
56+
57+
return rendered
58+
}
59+
60+
var inputPrompt: String? {
61+
return "\nRun this snippet? [R: run, or press Enter to return]"
62+
}
63+
64+
func acceptLineInput<S>(_ line: S) -> CardEvent? where S : StringProtocol {
65+
let trimmed = line.drop { $0.isWhitespace }.prefix { !$0.isWhitespace }.lowercased()
66+
guard !trimmed.isEmpty else {
67+
return .pop()
68+
}
69+
70+
switch trimmed {
71+
case "r", "run":
72+
do {
73+
try runExample()
74+
} catch {
75+
return .pop(SnippetCard.Error.cantRunSnippet(reason: error.localizedDescription))
76+
}
77+
break
78+
case "c", "copy":
79+
print("Unimplemented")
80+
break
81+
default:
82+
break
83+
}
84+
85+
return .pop()
86+
}
87+
88+
func runExample() throws {
89+
print("Building '\(snippet.path)'\n")
90+
let buildSystem = try swiftTool.createBuildSystem(explicitProduct: snippet.name)
91+
try buildSystem.build(subset: .product(snippet.name))
92+
let executablePath = try swiftTool.buildParameters().buildPath.appending(component: snippet.name)
93+
if let exampleTarget = try buildSystem.getPackageGraph().allTargets.first(where: { $0.name == snippet.name }) {
94+
try ProcessEnv.chdir(exampleTarget.sources.paths[0].parentDirectory)
95+
}
96+
try exec(path: executablePath.pathString, args: [])
97+
}
98+
}

0 commit comments

Comments
 (0)