1: <?php declare(strict_types=1);
2:
3: namespace Salient\Console\Target;
4:
5: use Salient\Console\Concept\ConsoleStreamTarget;
6: use Salient\Contract\Core\EscapeSequence;
7: use Salient\Contract\Core\MessageLevel as Level;
8: use Salient\Core\Facade\Err;
9: use Salient\Utility\Exception\InvalidArgumentTypeException;
10: use Salient\Utility\File;
11: use Salient\Utility\Str;
12: use DateTime;
13: use DateTimeZone;
14: use LogicException;
15:
16: /**
17: * Writes console output to a PHP stream
18: */
19: final class StreamTarget extends ConsoleStreamTarget
20: {
21: public const DEFAULT_TIMESTAMP_FORMAT = '[d M y H:i:s.vO] ';
22:
23: /** @var resource|null */
24: private $Stream;
25: private bool $IsCloseable;
26: private ?string $Uri;
27: private bool $AddTimestamp;
28: private string $TimestampFormat;
29: private ?DateTimeZone $Timezone;
30: private bool $IsStdout;
31: private bool $IsStderr;
32: private bool $IsTty;
33: private ?string $Path = null;
34: private static bool $HasPendingClearLine = false;
35:
36: /**
37: * @param resource $stream
38: * @param DateTimeZone|string|null $timezone
39: */
40: private function __construct(
41: $stream,
42: bool $closeable,
43: ?bool $addTimestamp,
44: ?string $timestampFormat,
45: $timezone
46: ) {
47: $this->applyStream($stream);
48:
49: $this->IsCloseable = $closeable;
50: $this->AddTimestamp = $addTimestamp || (
51: $addTimestamp === null && !$this->IsStdout && !$this->IsStderr
52: );
53:
54: if ($this->AddTimestamp) {
55: $this->TimestampFormat = Str::coalesce(
56: $timestampFormat,
57: self::DEFAULT_TIMESTAMP_FORMAT,
58: );
59: $this->Timezone = is_string($timezone)
60: ? new DateTimeZone($timezone)
61: : $timezone;
62: }
63: }
64:
65: /**
66: * @param resource $stream
67: */
68: private function applyStream($stream): void
69: {
70: $meta = stream_get_meta_data($stream);
71:
72: $this->Stream = $stream;
73: $this->Uri = $meta['uri'] ?? null;
74: $this->IsStdout = $this->Uri === 'php://stdout';
75: $this->IsStderr = $this->Uri === 'php://stderr';
76: $this->IsTty = stream_isatty($stream);
77:
78: stream_set_write_buffer($stream, 0);
79: }
80:
81: /**
82: * @internal
83: */
84: public function __destruct()
85: {
86: $this->close();
87: }
88:
89: /**
90: * @inheritDoc
91: */
92: public function isStdout(): bool
93: {
94: return $this->IsStdout;
95: }
96:
97: /**
98: * @inheritDoc
99: */
100: public function isStderr(): bool
101: {
102: return $this->IsStderr;
103: }
104:
105: /**
106: * @inheritDoc
107: */
108: public function isTty(): bool
109: {
110: return $this->IsTty;
111: }
112:
113: /**
114: * @inheritDoc
115: */
116: public function close(): void
117: {
118: if (!$this->Stream) {
119: return;
120: }
121:
122: if ($this->IsTty && self::$HasPendingClearLine && is_resource($this->Stream)) {
123: $this->clearLine(true);
124: }
125:
126: if ($this->IsCloseable) {
127: File::close($this->Stream, $this->Path);
128: }
129:
130: $this->Stream = null;
131: $this->Uri = null;
132: $this->IsStdout = false;
133: $this->IsStderr = false;
134: $this->IsTty = false;
135: $this->Path = null;
136: $this->setPrefix(null);
137: }
138:
139: /**
140: * @inheritDoc
141: */
142: public function reopen(?string $path = null): void
143: {
144: $this->assertIsValid();
145:
146: if ($this->Path === null) {
147: throw new LogicException(sprintf(
148: 'Only instances created by %s::fromPath() can be reopened',
149: static::class,
150: ));
151: }
152:
153: if ($path === null || $path === '') {
154: $path = $this->Path;
155: }
156:
157: File::close($this->Stream, $this->Path);
158:
159: if (!File::same($path, $this->Path)) {
160: File::create($path, 0600);
161: }
162:
163: $stream = File::open($path, 'a');
164: $this->applyStream($stream);
165: $this->Path = $path;
166: }
167:
168: /**
169: * Creates a new StreamTarget object backed by an open PHP stream
170: *
171: * @param resource $stream
172: * @param bool $closeable If `true`, call {@see File::close()} to close
173: * `$stream` when the target is closed.
174: * @param bool|null $addTimestamp If `null`, add timestamps if `$stream` is
175: * not `STDOUT` or `STDERR`.
176: * @param DateTimeZone|string|null $timezone If `null`, the timezone
177: * returned by {@see date_default_timezone_get()} is used.
178: */
179: public static function fromStream(
180: $stream,
181: bool $closeable = false,
182: ?bool $addTimestamp = null,
183: ?string $timestampFormat = StreamTarget::DEFAULT_TIMESTAMP_FORMAT,
184: $timezone = null
185: ): self {
186: if (!File::isStream($stream)) {
187: throw new InvalidArgumentTypeException(1, 'stream', 'resource (stream)', $stream);
188: }
189:
190: return new self($stream, $closeable, $addTimestamp, $timestampFormat, $timezone);
191: }
192:
193: /**
194: * Open a file in append mode and return a console output target for it
195: *
196: * @param bool|null $addTimestamp If `null`, add timestamps if `$path` does
197: * not resolve to `STDOUT` or `STDERR`.
198: * @param DateTimeZone|string|null $timezone If `null`, the timezone
199: * returned by {@see date_default_timezone_get()} is used.
200: */
201: public static function fromPath(
202: string $path,
203: ?bool $addTimestamp = null,
204: ?string $timestampFormat = StreamTarget::DEFAULT_TIMESTAMP_FORMAT,
205: $timezone = null
206: ): self {
207: File::create($path, 0600);
208: $stream = File::open($path, 'a');
209: $instance = new self($stream, true, $addTimestamp, $timestampFormat, $timezone);
210: $instance->Path = $path;
211: return $instance;
212: }
213:
214: /**
215: * @inheritDoc
216: */
217: protected function writeToTarget($level, string $message, array $context): void
218: {
219: $this->assertIsValid();
220:
221: if ($this->AddTimestamp) {
222: $now = (new DateTime('now', $this->Timezone))->format($this->TimestampFormat);
223: $message = $now . str_replace("\n", "\n" . $now, $message);
224: }
225:
226: // If writing a progress message to a TTY, suppress the usual newline
227: // and write a "clear to end of line" sequence before the next message
228: if ($this->IsTty) {
229: if (self::$HasPendingClearLine) {
230: $this->clearLine($level < Level::WARNING);
231: }
232: if ($message === "\r") {
233: return;
234: }
235: if ($message !== '' && $message[-1] === "\r") {
236: File::write($this->Stream, EscapeSequence::WRAP_OFF . rtrim($message, "\r"));
237: self::$HasPendingClearLine = true;
238: return;
239: }
240: }
241:
242: File::write($this->Stream, rtrim($message, "\r") . "\n");
243: }
244:
245: public function getPath(): ?string
246: {
247: return $this->Path;
248: }
249:
250: /**
251: * @phpstan-assert !null $this->Stream
252: */
253: protected function assertIsValid(): void
254: {
255: if (!$this->Stream) {
256: throw new LogicException('Target is closed');
257: }
258: }
259:
260: /**
261: * @phpstan-assert !null $this->Stream
262: */
263: private function clearLine(bool $preserveOutputOnError = false): void
264: {
265: $this->assertIsValid();
266:
267: $data = $preserveOutputOnError
268: && Err::isLoaded()
269: && Err::isShuttingDownOnError()
270: ? EscapeSequence::WRAP_ON . "\n"
271: : "\r" . EscapeSequence::CLEAR_LINE . EscapeSequence::WRAP_ON;
272: File::write($this->Stream, $data);
273: self::$HasPendingClearLine = false;
274: }
275: }
276: