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: | |
18: | |
19: | final class StreamTarget extends ConsoleStreamTarget |
20: | { |
21: | public const DEFAULT_TIMESTAMP_FORMAT = '[d M y H:i:s.vO] '; |
22: | |
23: | |
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: | |
38: | |
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: | |
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: | |
83: | |
84: | public function __destruct() |
85: | { |
86: | $this->close(); |
87: | } |
88: | |
89: | |
90: | |
91: | |
92: | public function isStdout(): bool |
93: | { |
94: | return $this->IsStdout; |
95: | } |
96: | |
97: | |
98: | |
99: | |
100: | public function isStderr(): bool |
101: | { |
102: | return $this->IsStderr; |
103: | } |
104: | |
105: | |
106: | |
107: | |
108: | public function isTty(): bool |
109: | { |
110: | return $this->IsTty; |
111: | } |
112: | |
113: | |
114: | |
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: | |
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: | |
170: | |
171: | |
172: | |
173: | |
174: | |
175: | |
176: | |
177: | |
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: | |
195: | |
196: | |
197: | |
198: | |
199: | |
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: | |
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: | |
227: | |
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: | |
252: | |
253: | protected function assertIsValid(): void |
254: | { |
255: | if (!$this->Stream) { |
256: | throw new LogicException('Target is closed'); |
257: | } |
258: | } |
259: | |
260: | |
261: | |
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: | |