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