22// From azure pipelines UI.
33// Class names have been changed to work with Primer styles
44// Source: https://github.com/microsoft/azure-devops-ui/blob/22b5ae5969d405f4459caf9b020019e95bbded38/packages/azure-pipelines-ui/src/Utilities/Parser.ts#L1
5- import { ILine , IParseNode , IParsedFindResult , NodeType } from './parserTypes'
6- import { TemplateResult , html } from 'lit-html/lit-html'
5+ import { IParseNode } from './parserTypes'
76
87// #region ANSII section
98
109const ESC = '\u001b'
11- const TimestampLength = 29
12- const TimestampRegex = / ^ .{ 27 } Z / gm
1310const BrightClassPostfix = '-br'
1411
1512// match characters that could be enclosing url to cleanly handle url formatting
1613export const URLRegex = / ( [ { ( [ ] * h t t p s ? : \/ \/ [ a - z 0 - 9 ] + (?: - [ a - z 0 - 9 ] + ) * \. [ ^ \s < > | ' " , ] { 2 , } ) / gi
17- // URLs in logs can be wrapped in punctuation to assist with parsing
18- const URLPunctuation = {
19- '(' : ')' ,
20- '[' : ']' ,
21- '{' : '}'
22- }
23- type URLStartPunctuation = keyof typeof URLPunctuation
24- type URLEndPunctuation = typeof URLPunctuation [ URLStartPunctuation ]
25- const matchingURLPunctuation = ( ch : string ) : URLEndPunctuation => URLPunctuation [ ch as URLStartPunctuation ]
26- const regexpEscape = ( ch : string ) => `\\${ ch } `
2714
2815/**
2916 * Regex for matching ANSII escape codes
@@ -148,20 +135,6 @@ const END_GROUP = 'endgroup'
148135const ICON = 'icon'
149136const NOTICE = 'notice'
150137
151- const commandToType : { [ command : string ] : NodeType } = {
152- command : NodeType . Command ,
153- debug : NodeType . Debug ,
154- error : NodeType . Error ,
155- info : NodeType . Info ,
156- section : NodeType . Section ,
157- verbose : NodeType . Verbose ,
158- warning : NodeType . Warning ,
159- notice : NodeType . Notice ,
160- group : NodeType . Group ,
161- endgroup : NodeType . EndGroup ,
162- icon : NodeType . Icon
163- }
164-
165138const typeToCommand : { [ type : string ] : string } = {
166139 '0' : PLAIN ,
167140 '1' : COMMAND ,
@@ -177,10 +150,6 @@ const typeToCommand: {[type: string]: string} = {
177150 '11' : NOTICE
178151}
179152
180- // Store the max command length we support, for example, we support "section", "command" which are of length 7, which highest of all others
181- const maxCommandLength = 8
182- const supportedCommands = [ COMMAND , DEBUG , ERROR , INFO , SECTION , VERBOSE , WARNING , GROUP , END_GROUP , ICON , NOTICE ]
183-
184153export function getType ( node : IParseNode ) {
185154 return typeToCommand [ node . type ]
186155}
@@ -213,310 +182,11 @@ interface IAnsiEscapeCodeState {
213182 style ?: IStyle
214183}
215184
216- enum CharacterType {
217- Standard ,
218- Search ,
219- EOL
220- }
221185
222186// Set max to prevent any perf degradations
223187export const maxLineMatchesToParse = 100
224188
225- const maxMatches = 50
226- const unsetValue = - 1
227- const newLineChar = '\n'
228- const hashChar = '#'
229- const commandStart = '['
230- const commandEnd = ']'
231-
232189export class Parser {
233- /**
234- * Converts the content to HTML with appropriate styles, escapes content to prevent XSS
235- * @param content
236- */
237- public parse ( content : string ) : TemplateResult {
238- let result = html ``
239- const states = this . getStates ( content )
240- for ( const x of states ) {
241- const classNames : string [ ] = [ ]
242- const styles : string [ ] = [ ]
243- const currentText = x . output
244- if ( x . style ) {
245- const fg = x . style . fg
246- const bg = x . style . bg
247- const isFgRGB = x . style . isFgRGB
248- const isBgRGB = x . style . isBgRGB
249- if ( fg && ! isFgRGB ) {
250- classNames . push ( `ansifg-${ fg } ` )
251- }
252- if ( bg && ! isBgRGB ) {
253- classNames . push ( `ansibg-${ bg } ` )
254- classNames . push ( `d-inline-flex` )
255- }
256- if ( fg && isFgRGB ) {
257- styles . push ( `color:rgb(${ fg } )` )
258- }
259- if ( bg && isBgRGB ) {
260- styles . push ( `background-color:rgb(${ bg } )` )
261- classNames . push ( `d-inline-flex` )
262- }
263- if ( x . style . bold ) {
264- classNames . push ( 'text-bold' )
265- }
266- if ( x . style . italic ) {
267- classNames . push ( 'text-italic' )
268- }
269- if ( x . style . underline ) {
270- classNames . push ( 'text-underline' )
271- }
272- }
273-
274- let output
275- const parseResult = Array ( currentText . length ) . fill ( CharacterType . Standard )
276-
277- result = html `${ result } < span class ="${ classNames . join ( ' ' ) } " style ="${ styles . join ( ';' ) } "> ${ output } </ span > `
278- }
279-
280- return result
281- }
282-
283- /**
284- * Parses the content into lines with nodes
285- * @param content content to parse
286- */
287- public parseLines ( content : string ) : ILine [ ] {
288- // lines we return
289- const lines : ILine [ ] = [ ]
290- // accumulated nodes for a particular line
291- let nodes : IParseNode [ ] = [ ]
292-
293- // start of a particular line
294- let lineStartIndex = 0
295- // start of plain node content
296- let plainNodeStart = unsetValue
297-
298- // tells to consider the default logic where we check for plain text etc.,
299- let considerDefaultLogic = true
300-
301- // stores the command, to match one of the 'supportedCommands'
302- let currentCommand = ''
303- // helps in finding commands in our format "##[command]" or "[command]"
304- let commandSeeker = ''
305-
306- // when line ends, this tells if there's any pending node
307- let pendingLastNode : number = unsetValue
308-
309- const resetCommandVar = ( ) => {
310- commandSeeker = ''
311- currentCommand = ''
312- }
313-
314- const resetPlain = ( ) => {
315- plainNodeStart = unsetValue
316- }
317-
318- const resetPending = ( ) => {
319- pendingLastNode = unsetValue
320- }
321-
322- const parseCommandEnd = ( ) => {
323- // possible continuation of our well-known commands
324- const commandIndex = supportedCommands . indexOf ( currentCommand )
325- if ( commandIndex !== - 1 ) {
326- considerDefaultLogic = false
327- // we reached the end and found the command
328- resetPlain ( )
329- // command is for the whole line, so we are not pushing the node here but defering this to when we find the new line
330- pendingLastNode = commandToType [ currentCommand ]
331-
332- if (
333- currentCommand === SECTION ||
334- currentCommand === GROUP ||
335- currentCommand === END_GROUP ||
336- currentCommand === COMMAND ||
337- currentCommand === ERROR ||
338- currentCommand === WARNING ||
339- currentCommand === NOTICE
340- ) {
341- // strip off ##[$(currentCommand)] if there are no timestamps at start
342- const possibleTimestamp = content . substring ( lineStartIndex , lineStartIndex + TimestampLength ) || ''
343- if ( ! possibleTimestamp . match ( TimestampRegex ) ) {
344- // Replace command only if it's found at the beginning of the line
345- if ( possibleTimestamp . indexOf ( currentCommand ) < 4 ) {
346- // ## is optional, so pick the right offset
347- const offset = content . substring ( lineStartIndex , lineStartIndex + 2 ) === '##' ? 4 : 2
348- lineStartIndex = lineStartIndex + offset + currentCommand . length
349- }
350- }
351- }
352-
353- if ( currentCommand === GROUP ) {
354- groupStarted = true
355- }
356-
357- // group logic- happyCase1: we found endGroup and there's already a group starting
358- if ( currentCommand === END_GROUP && currentGroupNode ) {
359- groupEnded = true
360- }
361- }
362-
363- resetCommandVar ( )
364- }
365-
366- let groupStarted = false
367- let groupEnded = false
368- let currentGroupNode : IParseNode | undefined
369- let nodeIndex = 0
370- let groupCount = 0
371-
372- for ( let index = 0 ; index < content . length ; index ++ ) {
373- const char = content . charAt ( index )
374- // start with considering default logic, individual conditions are responsible to set it false
375- considerDefaultLogic = true
376- if ( char === newLineChar || index === content . length - 1 ) {
377- if ( char === commandEnd ) {
378- parseCommandEnd ( )
379- }
380-
381- const node = {
382- type : pendingLastNode ,
383- start : lineStartIndex ,
384- end : index ,
385- lineIndex : lines . length ,
386- groupCount
387- } as IParseNode
388-
389- let pushNode = false
390- // end of the line/text, push any final nodes
391- if ( pendingLastNode !== NodeType . Plain ) {
392- // there's pending special node to be pushed
393- pushNode = true
394- // a new group has just started
395- if ( groupStarted ) {
396- currentGroupNode = node
397- groupStarted = false
398- }
399-
400- // a group has ended
401- if ( groupEnded && currentGroupNode ) {
402- // links to specifc lines in the UI need to match exactly what was provided by the runner for things like annotations so nodes can't be discarded
403- // lineIndexes are further adjusted based on the number of groups to ensure consistent and continuout numbering of lines in the UI
404- pushNode = true
405- node . group = {
406- lineIndex : currentGroupNode . lineIndex - 1 ,
407- nodeIndex : currentGroupNode . index
408- }
409- node . groupCount = groupCount
410- currentGroupNode . isGroup = true
411-
412- // since group has ended, clear all of our pointers
413- groupEnded = false
414- currentGroupNode = undefined
415- groupCount ++
416- }
417- } else if ( pendingLastNode === NodeType . Plain ) {
418- // there's pending plain node to be pushed
419- pushNode = true
420- }
421-
422- if ( pushNode ) {
423- node . index = nodeIndex ++
424- nodes . push ( node )
425- }
426-
427- // A group is pending
428- if ( currentGroupNode && node !== currentGroupNode ) {
429- node . group = {
430- lineIndex : currentGroupNode . lineIndex ,
431- nodeIndex : currentGroupNode . index
432- }
433- }
434-
435- // end of the line, push all nodes that are accumulated till now
436- if ( nodes . length > 0 ) {
437- lines . push ( { nodes} )
438- }
439-
440- // clear node as we are done with the line
441- nodes = [ ]
442- // increment lineStart for the next line
443- lineStartIndex = index + 1
444- // unset
445- resetPlain ( )
446- resetPending ( )
447- resetCommandVar ( )
448-
449- considerDefaultLogic = false
450- } else if ( char === hashChar ) {
451- // possible start of our well-known commands
452- if ( commandSeeker === '' || commandSeeker === '#' ) {
453- considerDefaultLogic = false
454- commandSeeker += hashChar
455- }
456- } else if ( char === commandStart ) {
457- // possible continuation of our well-known commands
458- if ( commandSeeker === '##' ) {
459- considerDefaultLogic = false
460- commandSeeker += commandStart
461- } else if ( commandSeeker . length === 0 ) {
462- // covers - "", for live logs, commands will be of [section], with out "##"
463- considerDefaultLogic = false
464- commandSeeker += commandStart
465- }
466- } else if ( char === commandEnd ) {
467- if ( currentCommand === ICON ) {
468- const startIndex = index + 1
469- let endIndex = startIndex
470- for ( let i = startIndex ; i < content . length ; i ++ ) {
471- const iconChar = content [ i ]
472- if ( iconChar === ' ' ) {
473- endIndex = i
474- break
475- }
476- }
477- nodes . push ( {
478- type : NodeType . Icon ,
479- lineIndex : lines . length ,
480- start : startIndex ,
481- end : endIndex ,
482- index : nodeIndex ++ ,
483- groupCount
484- } )
485- // jump to post Icon content
486- index = endIndex + 1
487- lineStartIndex = index
488- continue
489- } else {
490- parseCommandEnd ( )
491- }
492- }
493-
494- if ( considerDefaultLogic ) {
495- if ( commandSeeker === '##[' || commandSeeker === '[' ) {
496- // it's possible that we are parsing a command
497- currentCommand += char . toLowerCase ( )
498- }
499-
500- if ( currentCommand . length > maxCommandLength ) {
501- // to avoid accumulating command unncessarily, let's check max length, if it exceeds, it's not a command
502- resetCommandVar ( )
503- }
504-
505- // considering as plain text
506- if ( plainNodeStart === unsetValue ) {
507- // we didn't set this yet, set now
508- plainNodeStart = lineStartIndex
509- // set pending node if there isn't one pending
510- if ( pendingLastNode === unsetValue ) {
511- pendingLastNode = NodeType . Plain
512- }
513- }
514- }
515- }
516-
517- return lines
518- }
519-
520190 /**
521191 * Parses the content into ANSII states
522192 * @param content content to parse
0 commit comments