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