Skip to content

Commit 8287b2e

Browse files
authored
feat: implement upstream tee features (#6)
* spike: rewrite * chore: update tests
1 parent 973da70 commit 8287b2e

File tree

2 files changed

+203
-102
lines changed

2 files changed

+203
-102
lines changed

Sources/Teemoji.swift

Lines changed: 94 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,3 @@
1-
/**
2-
# Teemoji
3-
4-
A command-line tool similar to the classic `tee` utility, but uses a Core ML model to predict an appropriate emoji to prepend each incoming line of text.
5-
6-
**Usage**
7-
```bash
8-
cat input.txt | teemoji [options] [FILE...]
9-
```
10-
11-
**Options**
12-
- `-a`, `--append`: Append to the given FILE(s), do not overwrite.
13-
- `-h`, `--help`: Display help information.
14-
15-
This tool reads from standard input, writes to standard output, and can also write to one or more files.
16-
*/
17-
181
import CoreML
192
import Foundation
203

@@ -23,134 +6,158 @@ import Foundation
236
/// This `@main` struct orchestrates parsing command-line arguments, opening files in append or write modes,
247
/// loading the `TeemojiClassifier` model, reading lines from standard input, and writing them (with an emoji) to
258
/// standard output and each specified file.
26-
279
@main
2810
struct Teemoji {
29-
/**
30-
The main function for the `Teemoji` CLI tool.
31-
32-
This function processes command-line arguments, manages file I/O, and uses the Core ML model to predict emojis for
33-
each line read from standard input.
34-
35-
- Parameters:
36-
- arguments: Typically includes the executable name and any flags / file paths.
37-
- Returns: Does not return; the process runs until EOF on standard input.
38-
*/
3911
static func main() {
40-
// Parse command-line arguments
12+
// Keep track of exit status, default success.
13+
var exitStatus: Int32 = 0
14+
// Parse command-line arguments.
4115
var arguments = CommandLine.arguments
42-
// The first argument is the executable name, so remove it.
43-
arguments.removeFirst()
16+
arguments.removeFirst() // remove executable name
17+
18+
// We support -a and -i, plus -h/--help.
19+
let appendFlagIndex = arguments.firstIndex(where: { $0 == "-a" || $0 == "--append" })
20+
let ignoreSigIntIndex = arguments.firstIndex(where: { $0 == "-i" })
21+
let helpFlagIndex = arguments.firstIndex(where: { $0 == "-h" || $0 == "--help" })
22+
23+
let append = (appendFlagIndex != nil)
24+
if let index = appendFlagIndex {
25+
arguments.remove(at: index)
26+
}
27+
28+
// If -i is present, ignore SIGINT.
29+
if let index = ignoreSigIntIndex {
30+
signal(SIGINT, SIG_IGN)
31+
arguments.remove(at: index)
32+
}
4433

45-
// Check if the user asked for help
46-
if arguments.contains("-h") || arguments.contains("--help") {
34+
// If -h or --help is present, print usage and exit.
35+
if helpFlagIndex != nil {
4736
printUsage()
4837
exit(EXIT_SUCCESS)
4938
}
5039

51-
// Check for append flag (-a / --append) and strip it out
52-
let append = arguments.contains("-a") || arguments.contains("--append")
53-
arguments.removeAll(where: { $0 == "-a" || $0 == "--append" })
54-
55-
// The remaining arguments are taken to be filenames (like `tee file1 file2 ...`)
40+
// Remaining arguments are treated as file paths.
5641
let fileURLs = arguments.map { URL(fileURLWithPath: $0) }
5742

58-
// Open file handles for writing or appending
59-
var fileHandles: [FileHandle] = []
43+
// Open file handles.
44+
var fileHandles: [(URL, FileHandle)] = []
6045
for url in fileURLs {
6146
do {
62-
// If appending, open or create the file; otherwise create/truncate it.
6347
if append {
64-
// Create the file if it doesn’t exist; otherwise open for appending.
48+
// Create file if it doesn’t exist, otherwise open for append.
6549
if !FileManager.default.fileExists(atPath: url.path) {
6650
FileManager.default.createFile(atPath: url.path, contents: nil)
6751
}
6852
let handle = try FileHandle(forWritingTo: url)
69-
// Move the write pointer to the end if appending
7053
try handle.seekToEnd()
71-
fileHandles.append(handle)
54+
fileHandles.append((url, handle))
7255
} else {
73-
// Overwrite by creating a new file (truncating existing contents)
56+
// Overwrite by creating a new file.
7457
FileManager.default.createFile(atPath: url.path, contents: nil)
7558
let handle = try FileHandle(forWritingTo: url)
76-
fileHandles.append(handle)
59+
fileHandles.append((url, handle))
7760
}
7861
} catch {
7962
fputs("teemoji: cannot open \(url.path): \(error)\n", stderr)
63+
exitStatus = 1
8064
}
8165
}
8266

83-
// Make sure handles are closed at the end
67+
// Ensure handles get closed.
8468
defer {
85-
for handle in fileHandles {
69+
for (_, handle) in fileHandles {
8670
try? handle.close()
8771
}
8872
}
8973

74+
// Load the ML model.
9075
guard
9176
let modelURL = Bundle.module.url(
92-
forResource: "TeemojiClassifier", withExtension: "mlmodelc")
77+
forResource: "TeemojiClassifier", withExtension: "mlmodelc"),
78+
let rawModel = try? MLModel(contentsOf: modelURL)
9379
else {
9480
fputs("teemoji: failed to load CoreML model.\n", stderr)
9581
exit(EXIT_FAILURE)
9682
}
97-
98-
guard let rawModel = try? MLModel(contentsOf: modelURL) else {
99-
fputs("teemoji: failed to load CoreML model.\n", stderr)
100-
exit(EXIT_FAILURE)
101-
}
102-
10383
let model = TeemojiClassifier(model: rawModel)
10484

105-
// Read lines from stdin, predict an emoji, write to stdout & files
85+
// Read from stdin line by line, predict emoji, then write to stdout & all open files.
10686
while let line = readLine() {
107-
// Attempt inference on the line
87+
// Attempt model inference.
10888
let predictionLabel: String
10989
do {
11090
let prediction = try model.prediction(text: line)
11191
predictionLabel = prediction.label
11292
} catch {
113-
// If model prediction fails for any reason, fall back to no emoji
93+
// If model fails, use a fallback.
11494
predictionLabel = ""
11595
}
11696

97+
// Prepare output line.
11798
let outputLine = "\(predictionLabel) \(line)\n"
99+
// Always write to stdout.
100+
if fputs(outputLine, stdout) < 0 {
101+
// If an error occurs while writing to stdout, set exit code.
102+
exitStatus = 1
103+
}
118104

119-
// Always write to stdout
120-
fputs(outputLine, stdout)
121-
122-
// Also write to each open file
105+
// Also attempt to write to each file.
123106
if let data = outputLine.data(using: .utf8) {
124-
for handle in fileHandles {
125-
do {
126-
try handle.write(contentsOf: data)
127-
} catch {
128-
fputs("teemoji: error writing to file: \(error)\n", stderr)
107+
for (url, handle) in fileHandles {
108+
var offset = 0
109+
let length = data.count
110+
// Attempt partial-write logic to ensure all data is written.
111+
while offset < length {
112+
do {
113+
// We slice the data from the offset onward.
114+
let sliceSize = try handle.writeCompat(
115+
data: data, offset: offset, length: length - offset)
116+
if sliceSize <= 0 {
117+
// Zero or negative means we couldn't write.
118+
throw NSError(domain: "WriteError", code: 1, userInfo: nil)
119+
}
120+
offset += sliceSize
121+
} catch {
122+
fputs("teemoji: error writing to \(url.path): \(error)\n", stderr)
123+
exitStatus = 1
124+
break
125+
}
129126
}
130127
}
131128
}
132129
}
130+
131+
// Since readLine() returns nil on EOF or error, we can’t distinguish. Just exit.
132+
exit(exitStatus)
133+
}
134+
135+
/// Prints usage, matching FreeBSD tee’s style.
136+
static func printUsage() {
137+
let usage = """
138+
usage: teemoji [-ai] [file ...]
139+
140+
Reads from standard input, writes to standard output and specified files, prepending an emoji
141+
inferred by a Core ML model to each line. Options:
142+
-a\tAppend to the given file(s), do not overwrite
143+
-i\tIgnore SIGINT
144+
-h\tDisplay help (non-standard extension)
145+
"""
146+
print(usage)
133147
}
134148
}
135149

136-
/// Prints usage information to stdout.
137-
///
138-
/// Call this function when `-h` or `--help` flags are detected, or whenever you want
139-
/// to remind users how to operate the tool.
140-
func printUsage() {
141-
let usage = """
142-
Usage: teemoji [options] [FILE...]
143-
144-
Like the standard 'tee' command, teemoji reads from standard input and writes to standard output
145-
and the specified FILEs, but prepends an emoji to each line inferred by a Core ML model.
146-
147-
Options:
148-
-a, --append Append to the given FILE(s), do not overwrite.
149-
-h, --help Display this help message.
150-
151-
Examples:
152-
cat input.txt | teemoji output.txt
153-
cat input.txt | teemoji -a output.txt another.log
154-
"""
155-
print(usage)
150+
// Extend FileHandle to do partial writes similar to POSIX.
151+
extension FileHandle {
152+
/// Write a segment of `data` starting from `offset`, returning how many bytes were written.
153+
fileprivate func writeCompat(data: Data, offset: Int, length: Int) throws -> Int {
154+
// Slicing the data.
155+
let subdata = data.subdata(in: offset..<(offset + length))
156+
// On Apple platforms, `write` should typically write all data, but we mimic partial writes.
157+
// We'll try writing all subdata at once. If it succeeds, it’s done.
158+
// If it fails, we throw.
159+
// This is a simplified approach: we assume full write or error.
160+
self.write(subdata)
161+
return subdata.count
162+
}
156163
}

0 commit comments

Comments
 (0)