Skip to content

Commit f4ae0fa

Browse files
authored
CM-45719 - Add syntax highlight for code snippets in text output (#290)
1 parent 72e8b77 commit f4ae0fa

File tree

4 files changed

+90
-142
lines changed

4 files changed

+90
-142
lines changed

cycode/cli/printers/printer_base.py

-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717

1818
class PrinterBase(ABC):
1919
RED_COLOR_NAME = 'red'
20-
WHITE_COLOR_NAME = 'white'
2120
GREEN_COLOR_NAME = 'green'
2221

2322
def __init__(self, ctx: typer.Context) -> None:

cycode/cli/printers/text_printer.py

+87-135
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import math
2+
import urllib.parse
23
from typing import TYPE_CHECKING, Dict, List, Optional
34

4-
import click
55
import typer
6+
from rich.console import Console
7+
from rich.markup import escape
8+
from rich.syntax import Syntax
69

10+
from cycode.cli.cli_types import SeverityOption
711
from cycode.cli.consts import COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES, SECRET_SCAN_TYPE
812
from cycode.cli.models import CliError, CliResult, Detection, Document, DocumentDetections
913
from cycode.cli.printers.printer_base import PrinterBase
@@ -25,73 +29,80 @@ def print_result(self, result: CliResult) -> None:
2529
if not result.success:
2630
color = self.RED_COLOR_NAME
2731

28-
click.secho(result.message, fg=color)
32+
typer.secho(result.message, fg=color)
2933

3034
if not result.data:
3135
return
3236

33-
click.secho('\nAdditional data:', fg=color)
37+
typer.secho('\nAdditional data:', fg=color)
3438
for name, value in result.data.items():
35-
click.secho(f'- {name}: {value}', fg=color)
39+
typer.secho(f'- {name}: {value}', fg=color)
3640

3741
def print_error(self, error: CliError) -> None:
38-
click.secho(error.message, fg=self.RED_COLOR_NAME)
42+
typer.secho(error.message, fg=self.RED_COLOR_NAME)
3943

4044
def print_scan_results(
4145
self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None
4246
) -> None:
4347
if not errors and all(result.issue_detected == 0 for result in local_scan_results):
44-
click.secho('Good job! No issues were found!!! 👏👏👏', fg=self.GREEN_COLOR_NAME)
48+
typer.secho('Good job! No issues were found!!! 👏👏👏', fg=self.GREEN_COLOR_NAME)
4549
return
4650

4751
for local_scan_result in local_scan_results:
4852
for document_detections in local_scan_result.document_detections:
49-
self._print_document_detections(document_detections, local_scan_result.scan_id)
53+
self._print_document_detections(document_detections)
5054

5155
report_urls = [scan_result.report_url for scan_result in local_scan_results if scan_result.report_url]
5256

5357
self._print_report_urls(report_urls, self.ctx.obj.get('aggregation_report_url'))
5458
if not errors:
5559
return
5660

57-
click.secho(
61+
typer.secho(
5862
'Unfortunately, Cycode was unable to complete the full scan. '
5963
'Please note that not all results may be available:',
6064
fg='red',
6165
)
6266
for scan_id, error in errors.items():
63-
click.echo(f'- {scan_id}: ', nl=False)
67+
typer.echo(f'- {scan_id}: ', nl=False)
6468
self.print_error(error)
6569

66-
def _print_document_detections(self, document_detections: DocumentDetections, scan_id: str) -> None:
70+
def _print_document_detections(self, document_detections: DocumentDetections) -> None:
6771
document = document_detections.document
6872
for detection in document_detections.detections:
69-
self._print_detection_summary(detection, document.path, scan_id)
73+
self._print_detection_summary(detection, document.path)
74+
self._print_new_line()
7075
self._print_detection_code_segment(detection, document)
76+
self._print_new_line()
7177

72-
def _print_detection_summary(self, detection: Detection, document_path: str, scan_id: str) -> None:
78+
@staticmethod
79+
def _print_new_line() -> None:
80+
typer.echo()
81+
82+
def _print_detection_summary(self, detection: Detection, document_path: str) -> None:
7383
detection_name = detection.type if self.scan_type == SECRET_SCAN_TYPE else detection.message
74-
detection_name_styled = click.style(detection_name, fg='bright_red', bold=True)
7584

76-
detection_sha = detection.detection_details.get('sha512')
77-
detection_sha_message = f'\nSecret SHA: {detection_sha}' if detection_sha else ''
85+
detection_severity = detection.severity or 'N/A'
86+
detection_severity_color = SeverityOption.get_member_color(detection_severity)
87+
detection_severity = f'[{detection_severity_color}]{detection_severity.upper()}[/{detection_severity_color}]'
88+
89+
escaped_document_path = escape(urllib.parse.quote(document_path))
90+
clickable_document_path = f'[link file://{escaped_document_path}]{document_path}'
7891

79-
scan_id_message = f'\nScan ID: {scan_id}'
8092
detection_commit_id = detection.detection_details.get('commit_id')
8193
detection_commit_id_message = f'\nCommit SHA: {detection_commit_id}' if detection_commit_id else ''
8294

8395
company_guidelines = detection.detection_details.get('custom_remediation_guidelines')
8496
company_guidelines_message = f'\nCompany Guideline: {company_guidelines}' if company_guidelines else ''
8597

86-
click.echo(
87-
f'⛔ '
88-
f'Found issue of type: {detection_name_styled} '
89-
f'(rule ID: {detection.detection_rule_id}) in file: {click.format_filename(document_path)} '
90-
f'{detection_sha_message}'
91-
f'{scan_id_message}'
98+
Console().print(
99+
f':no_entry: '
100+
f'Found {detection_severity} issue of type: [bright_red][bold]{detection_name}[/bold][/bright_red] '
101+
f'in file: {clickable_document_path} '
92102
f'{detection_commit_id_message}'
93103
f'{company_guidelines_message}'
94-
f' ⛔'
104+
f' :no_entry:',
105+
highlight=True,
95106
)
96107

97108
def _print_detection_code_segment(
@@ -109,145 +120,86 @@ def _print_report_urls(report_urls: List[str], aggregation_report_url: Optional[
109120
if not report_urls and not aggregation_report_url:
110121
return
111122
if aggregation_report_url:
112-
click.echo(f'Report URL: {aggregation_report_url}')
123+
typer.echo(f'Report URL: {aggregation_report_url}')
113124
return
114125

115-
click.echo('Report URLs:')
126+
typer.echo('Report URLs:')
116127
for report_url in report_urls:
117-
click.echo(f'- {report_url}')
128+
typer.echo(f'- {report_url}')
118129

119130
@staticmethod
120131
def _get_code_segment_start_line(detection_line: int, lines_to_display: int) -> int:
121132
start_line = detection_line - math.ceil(lines_to_display / 2)
122133
return 0 if start_line < 0 else start_line
123134

124-
def _print_line_of_code_segment(
125-
self,
126-
document: Document,
127-
line: str,
128-
line_number: int,
129-
detection_position_in_line: int,
130-
violation_length: int,
131-
is_detection_line: bool,
132-
) -> None:
133-
if is_detection_line:
134-
self._print_detection_line(document, line, line_number, detection_position_in_line, violation_length)
135-
else:
136-
self._print_line(document, line, line_number)
137-
138-
def _print_detection_line(
139-
self, document: Document, line: str, line_number: int, detection_position_in_line: int, violation_length: int
140-
) -> None:
141-
detection_line = self._get_detection_line_style(
142-
line, document.is_git_diff_format, detection_position_in_line, violation_length
143-
)
144-
145-
click.echo(f'{self._get_line_number_style(line_number)} {detection_line}')
146-
147-
def _print_line(self, document: Document, line: str, line_number: int) -> None:
148-
line_no = self._get_line_number_style(line_number)
149-
line = self._get_line_style(line, document.is_git_diff_format)
150-
151-
click.echo(f'{line_no} {line}')
152-
153-
def _get_detection_line_style(self, line: str, is_git_diff: bool, start_position: int, length: int) -> str:
154-
line_color = self._get_line_color(line, is_git_diff)
155-
if self.scan_type != SECRET_SCAN_TYPE or start_position < 0 or length < 0:
156-
return self._get_line_style(line, is_git_diff, line_color)
157-
158-
violation = line[start_position : start_position + length]
159-
if not self.show_secret:
160-
violation = obfuscate_text(violation)
161-
162-
line_to_violation = line[0:start_position]
163-
line_from_violation = line[start_position + length :]
164-
165-
return (
166-
f'{self._get_line_style(line_to_violation, is_git_diff, line_color)}'
167-
f'{self._get_line_style(violation, is_git_diff, line_color, underline=True)}'
168-
f'{self._get_line_style(line_from_violation, is_git_diff, line_color)}'
169-
)
170-
171-
def _get_line_style(
172-
self, line: str, is_git_diff: bool, color: Optional[str] = None, underline: bool = False
173-
) -> str:
174-
if color is None:
175-
color = self._get_line_color(line, is_git_diff)
176-
177-
return click.style(line, fg=color, bold=False, underline=underline)
178-
179-
def _get_line_color(self, line: str, is_git_diff: bool) -> str:
180-
if not is_git_diff:
181-
return self.WHITE_COLOR_NAME
182-
183-
if line.startswith('+'):
184-
return self.GREEN_COLOR_NAME
185-
186-
if line.startswith('-'):
187-
return self.RED_COLOR_NAME
188-
189-
return self.WHITE_COLOR_NAME
190-
191-
def _get_line_number_style(self, line_number: int) -> str:
135+
def _get_detection_line(self, detection: Detection) -> int:
192136
return (
193-
f'{click.style(str(line_number), fg=self.WHITE_COLOR_NAME, bold=False)} '
194-
f'{click.style("|", fg=self.RED_COLOR_NAME, bold=False)}'
137+
detection.detection_details.get('line', -1)
138+
if self.scan_type == SECRET_SCAN_TYPE
139+
else detection.detection_details.get('line_in_file', -1) - 1
195140
)
196141

197142
def _print_detection_from_file(self, detection: Detection, document: Document, lines_to_display: int) -> None:
198143
detection_details = detection.detection_details
199-
detection_line = (
200-
detection_details.get('line', -1)
201-
if self.scan_type == SECRET_SCAN_TYPE
202-
else detection_details.get('line_in_file', -1)
203-
)
204-
detection_position = detection_details.get('start_position', -1)
144+
detection_line = self._get_detection_line(detection)
145+
start_line_index = self._get_code_segment_start_line(detection_line, lines_to_display)
146+
detection_position = get_position_in_line(document.content, detection_details.get('start_position', -1))
205147
violation_length = detection_details.get('length', -1)
206148

207-
file_content = document.content
208-
file_lines = file_content.splitlines()
209-
start_line = self._get_code_segment_start_line(detection_line, lines_to_display)
210-
detection_position_in_line = get_position_in_line(file_content, detection_position)
211-
212-
click.echo()
149+
code_lines_to_render = []
150+
document_content_lines = document.content.splitlines()
213151
for line_index in range(lines_to_display):
214-
current_line_index = start_line + line_index
215-
if current_line_index >= len(file_lines):
152+
current_line_index = start_line_index + line_index
153+
if current_line_index >= len(document_content_lines):
216154
break
217155

218-
current_line = file_lines[current_line_index]
219-
is_detection_line = current_line_index == detection_line
220-
self._print_line_of_code_segment(
221-
document,
222-
current_line,
223-
current_line_index + 1,
224-
detection_position_in_line,
225-
violation_length,
226-
is_detection_line,
156+
line_content = document_content_lines[current_line_index]
157+
158+
line_with_detection = current_line_index == detection_line
159+
if self.scan_type == SECRET_SCAN_TYPE and line_with_detection and not self.show_secret:
160+
violation = line_content[detection_position : detection_position + violation_length]
161+
code_lines_to_render.append(line_content.replace(violation, obfuscate_text(violation)))
162+
else:
163+
code_lines_to_render.append(line_content)
164+
165+
code_to_render = '\n'.join(code_lines_to_render)
166+
Console().print(
167+
Syntax(
168+
code=code_to_render,
169+
lexer=Syntax.guess_lexer(document.path, code=code_to_render),
170+
line_numbers=True,
171+
dedent=True,
172+
tab_size=2,
173+
start_line=start_line_index + 1,
174+
highlight_lines={
175+
detection_line + 1,
176+
},
227177
)
228-
click.echo()
178+
)
229179

230180
def _print_detection_from_git_diff(self, detection: Detection, document: Document) -> None:
231181
detection_details = detection.detection_details
232-
detection_line_number = detection_details.get('line', -1)
233-
detection_line_number_in_original_file = detection_details.get('line_in_file', -1)
182+
detection_line = self._get_detection_line(detection)
234183
detection_position = detection_details.get('start_position', -1)
235184
violation_length = detection_details.get('length', -1)
236185

237-
git_diff_content = document.content
238-
git_diff_lines = git_diff_content.splitlines()
239-
detection_line = git_diff_lines[detection_line_number]
240-
detection_position_in_line = get_position_in_line(git_diff_content, detection_position)
241-
242-
click.echo()
243-
self._print_detection_line(
244-
document,
245-
detection_line,
246-
detection_line_number_in_original_file,
247-
detection_position_in_line,
248-
violation_length,
186+
line_content = document.content.splitlines()[detection_line]
187+
detection_position_in_line = get_position_in_line(document.content, detection_position)
188+
if self.scan_type == SECRET_SCAN_TYPE and not self.show_secret:
189+
violation = line_content[detection_position_in_line : detection_position_in_line + violation_length]
190+
line_content = line_content.replace(violation, obfuscate_text(violation))
191+
192+
Console().print(
193+
Syntax(
194+
line_content,
195+
lexer='diff',
196+
line_numbers=True,
197+
start_line=detection_line,
198+
dedent=True,
199+
tab_size=2,
200+
highlight_lines={detection_line + 1},
201+
)
249202
)
250-
click.echo()
251203

252204
def _is_git_diff_based_scan(self) -> bool:
253205
return self.command_scan_type in COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES and self.scan_type == SECRET_SCAN_TYPE

cycode/cli/utils/progress_bar.py

+2-5
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,7 @@ def __init__(self, progress_bar_sections: ProgressBarSections) -> None:
145145
self._current_section: ProgressBarSectionInfo = _get_initial_section(self._progress_bar_sections)
146146
self._current_right_side_label = ''
147147

148-
self._progress_bar = Progress(
149-
*_PROGRESS_BAR_COLUMNS,
150-
transient=True,
151-
)
148+
self._progress_bar = Progress(*_PROGRESS_BAR_COLUMNS)
152149
self._progress_bar_task_id = self._progress_bar.add_task(
153150
description=self._current_section.label,
154151
total=_PROGRESS_BAR_LENGTH,
@@ -245,7 +242,7 @@ def update(self, section: 'ProgressBarSection', value: int = 1) -> None:
245242
self._maybe_update_current_section()
246243

247244
def update_right_side_label(self, label: Optional[str] = None) -> None:
248-
self._current_right_side_label = f'({label})' or ''
245+
self._current_right_side_label = f'({label})' if label else ''
249246
self._progress_bar_update()
250247

251248

tests/cli/commands/test_main_command.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ def test_passing_output_option(output: str, scan_client: 'ScanClient', api_token
4949
output = json.loads(result.output)
5050
assert 'scan_id' in output
5151
else:
52-
assert 'Scan ID' in result.output
52+
assert 'issue of type:' in result.output
5353

5454

5555
@responses.activate

0 commit comments

Comments
 (0)